20

私はHaskellを少し知っていますが、Haskellで次のすべての行列-行列積のようなものを書くことができるかどうか疑問に思います。

  1. 純粋関数型:型アノテーションにnoIOまたはStateモナドが含まれている(関数本体で何が起こるかは関係ありません。つまり、関数全体が純粋である限り、関数本体がモナドを使用するかどうかは関係ありません)。この行列-行列積を純粋関数で使用したいと思うかもしれません。
  2. メモリセーフ:mallocやポインタはありません。Haskellで「Cを書く」ことは可能ですが、メモリの安全性が失われます。実際にこのコードをCで記述し、Haskellとインターフェイスさせると、メモリの安全性も失われます。
  3. たとえば、Javaと同じくらい効率的です。具体的には、単純なトリプルループ、単精度、連続した列メジャーレイアウト(float[]ではなくfloat[][])、サイズ1000x1000の行列、およびシングルコアCPUについて話していると仮定します。(サイクルごとに0.5〜2の浮動小数点演算を取得している場合は、おそらく球場にいます。)

(私はこれが挑戦のように聞こえることを望んでいませんが、Javaは上記のすべてを簡単に満たすことができることに注意してください。)

私はすでにそれを知っています

  1. トリプルループの実装は、最も効率的な実装ではありません。それはかなりキャッシュを忘れます。この特定のケースでは、適切に記述されたBLAS実装を使用することをお勧めします。ただし、Cライブラリが自分のやろうとしていることに利用できるとは限りません。かなり効率的なコードを通常のHaskellで書くことができるのだろうか。
  2. 何人かの人々は#3を示す全体の研究論文を書きました。しかし、私はコンピュータサイエンスの研究者ではありません。Haskellでは単純なことを単純に保つことができるのだろうか。
  3. ハスケルの穏やかな紹介には、マトリックス製品の実装があります。ただし、上記の要件は満たされません。

コメントへの対処:

私には3つの理由があります。1つは、「mallocまたはポインターなし」の要件がまだ明確に定義されていないことです(ポインターを使用しないHaskellコードを作成するようにお願いします)。

を使用していないHaskellプログラムをたくさん見ましたPtr。おそらくそれは、機械命令レベルでポインタが使用されるという事実を指しているのでしょうか。そういう意味じゃない。私はHaskellソースコードの抽象化レベルを参照していました。

第二に、CS研究への攻撃は場違いです(さらに、他の誰かがあなたのためにすでに書いたコードを使用するよりも簡単なことは想像できません)。第三に、Hackageには多くのマトリックスパッケージがあります(そして、この質問をするための準備作業には、それぞれのレビューと拒否が含まれている必要があります)。

#2と#3は同じようです(「既存のライブラリを使用する」)。Haskellがそれ自体で何ができるか、そしてそれがあなたが「単純なことを単純に保つ」ことを可能にするかどうかの簡単なテストとして、マトリックス製品に興味があります。準備が整ったライブラリがない数値問題を簡単に思い付くことができましたが、問題を説明する必要がありますが、誰もが行列積が何であるかをすでに知っています。

Javaはどのようにして1を満足させることができますか?すべてのJavaメソッドは本質的に :: IORef Arg -> ... -> IORef This -> IO Ret

これは私の質問の根源になります、実際には(+1)。Javaは純度を追跡するとは主張していませんが、Haskellは追跡しています。Javaでは、関数が純粋であるかどうかがコメントに示されます。関数本体で突然変異を行っても、行列積は純粋であると言えます。問題は、Haskellのアプローチ(型システムでエンコードされた純度)が効率、メモリの安全性、および単純さと互換性があるかどうかです。

4

3 に答える 3

20

たとえば、Javaと同じくらい効率的です。具体的には、単純なトリプルループ、単精度、連続した列優先レイアウト(float [][]ではなくfloat[])、サイズ1000x1000の行列、およびシングルコアCPUについて話していると仮定します。(サイクルごとに0.5〜2の浮動小数点演算を取得している場合は、おそらく球場にいます)

だから何かのような

public class MatrixProd {
    static float[] matProd(float[] a, int ra, int ca, float[] b, int rb, int cb) {
        if (ca != rb) {
            throw new IllegalArgumentException("Matrices not fitting");
        }
        float[] c = new float[ra*cb];
        for(int i = 0; i < ra; ++i) {
            for(int j = 0; j < cb; ++j) {
                float sum = 0;
                for(int k = 0; k < ca; ++k) {
                    sum += a[i*ca+k]*b[k*cb+j];
                }
                c[i*cb+j] = sum;
            }
        }
        return c;
    }

    static float[] mkMat(int rs, int cs, float x, float d) {
        float[] arr = new float[rs*cs];
        for(int i = 0; i < rs; ++i) {
            for(int j = 0; j < cs; ++j) {
                arr[i*cs+j] = x;
                x += d;
            }
        }
        return arr;
    }

    public static void main(String[] args) {
        int sz = 100;
        float strt = -32, del = 0.0625f;
        if (args.length > 0) {
            sz = Integer.parseInt(args[0]);
        }
        if (args.length > 1) {
            strt = Float.parseFloat(args[1]);
        }
        if (args.length > 2) {
            del = Float.parseFloat(args[2]);
        }
        float[] a = mkMat(sz,sz,strt,del);
        float[] b = mkMat(sz,sz,strt-16,del);
        System.out.println(a[sz*sz-1]);
        System.out.println(b[sz*sz-1]);
        long t0 = System.currentTimeMillis();
        float[] c = matProd(a,sz,sz,b,sz,sz);
        System.out.println(c[sz*sz-1]);
        long t1 = System.currentTimeMillis();
        double dur = (t1-t0)*1e-3;
        System.out.println(dur);
    }
}

私は考えます?(コーディング前に仕様を正しく読んでいなかったので、レイアウトは行優先ですが、アクセスパターンが同じであるため、混合レイアウトの場合と違いはありません。それで問題ないと思います。)

私は巧妙なアルゴリズムや低レベルの最適化のトリックについて考えることに時間を費やしていません(とにかくそれらを使ってJavaで多くを達成することはありません)。単純なループを書いただけです。

これが挑戦のように聞こえたくないのですが、Javaは上記のすべてを簡単に満たすことができることに注意してください

そして、それはJavaが簡単に提供するものなので、私はそれを取り上げます。

(サイクルごとに0.5〜2の浮動小数点演算を取得している場合は、おそらく球場にいます)

JavaでもHaskellでも、私は恐れています。単純なトリプルループでそのスループットに到達するには、キャッシュミスが多すぎます。

Haskellでも同じことをしますが、これも賢いことを考えずに、単純で単純なトリプルループです。

{-# LANGUAGE BangPatterns #-}
module MatProd where

import Data.Array.ST
import Data.Array.Unboxed

matProd :: UArray Int Float -> Int -> Int -> UArray Int Float -> Int -> Int -> UArray Int Float
matProd a ra ca b rb cb =
    let (al,ah)     = bounds a
        (bl,bh)     = bounds b
        {-# INLINE getA #-}
        getA i j    = a!(i*ca + j)
        {-# INLINE getB #-}
        getB i j    = b!(i*cb + j)
        {-# INLINE idx #-}
        idx i j     = i*cb + j
    in if al /= 0 || ah+1 /= ra*ca || bl /= 0 || bh+1 /= rb*cb || ca /= rb
         then error $ "Matrices not fitting: " ++ show (ra,ca,al,ah,rb,cb,bl,bh)
         else runSTUArray $ do
            arr <- newArray (0,ra*cb-1) 0
            let outer i j
                    | ra <= i   = return arr
                    | cb <= j   = outer (i+1) 0
                    | otherwise = do
                        !x <- inner i j 0 0
                        writeArray arr (idx i j) x
                        outer i (j+1)
                inner i j k !y
                    | ca <= k   = return y
                    | otherwise = inner i j (k+1) (y + getA i k * getB k j)
            outer 0 0

mkMat :: Int -> Int -> Float -> Float -> UArray Int Float
mkMat rs cs x d = runSTUArray $ do
    let !r = rs - 1
        !c = cs - 1
        {-# INLINE idx #-}
        idx i j = cs*i + j
    arr <- newArray (0,rs*cs-1) 0
    let outer i j y
            | r < i     = return arr
            | c < j     = outer (i+1) 0 y
            | otherwise = do
                writeArray arr (idx i j) y
                outer i (j+1) (y + d)
    outer 0 0 x

および呼び出し元モジュール

module Main (main) where

import System.Environment (getArgs)
import Data.Array.Unboxed

import System.CPUTime
import Text.Printf

import MatProd

main :: IO ()
main = do
    args <- getArgs
    let (sz, strt, del) = case args of
                            (a:b:c:_) -> (read a, read b, read c)
                            (a:b:_)   -> (read a, read b, 0.0625)
                            (a:_)     -> (read a, -32, 0.0625)
                            _         -> (100, -32, 0.0625)
        a = mkMat sz sz strt del
        b = mkMat sz sz (strt - 16) del
    print (a!(sz*sz-1))
    print (b!(sz*sz-1))
    t0 <- getCPUTime
    let c = matProd a sz sz b sz sz
    print $ c!(sz*sz-1)
    t1 <- getCPUTime
    printf "%.6f\n" (fromInteger (t1-t0)*1e-12 :: Double)

したがって、両方の言語でほぼ同じことを行っています。Haskellをでコンパイルし-O2、Javaをjavacでコンパイルします

$ java MatrixProd 1000 "-13.7" 0.013
12915.623
12899.999
8.3592897E10
8.193
$ ./vmmult 1000 "-13.7" 0.013
12915.623
12899.999
8.35929e10
8.558699

そして、結果として生じる時間は非常に近いです。

そして、Javaコードをネイティブにコンパイルするとgcj -O3 -Wall -Wextra --main=MatrixProd -fno-bounds-check -fno-store-check -o jmatProd MatrixProd.java

$ ./jmatProd 1000 "-13.7" 0.013
12915.623
12899.999
8.3592896512E10
8.215

まだ大きな違いはありません。

特別なボーナスとして、Cの同じアルゴリズム(gcc -O3):

$ ./cmatProd 1000 "-13.7" 0.013
12915.623047
12899.999023
8.35929e+10
8.079759

したがって、これは、浮動小数点数を使用する計算集約型タスクに関して、単純なJavaと単純なHaskellの基本的な違いを明らかにしません(中規模から大規模の数値で整数演算を処理する場合、GHCによるGMPの使用により、HaskellはJavaのBigIntegerを大幅に上回ります多くのタスクで使用できますが、これはもちろんライブラリの問題であり、言語の問題ではありません)。このアルゴリズムでは、どちらもCに近いものです。

ただし、公平を期すために、アクセスパターンによって1ナノ秒おきにキャッシュミスが発生するため、3つの言語すべてで、この計算はメモリに依存します。

行メジャーマトリックスと列メジャーマトリックスを乗算してアクセスパターンを改善すると、すべてが高速になり、gccコンパイルされたCは1.18秒、javaは1.23秒、ghcコンパイルされたHaskellは約5.8秒かかります。 llvmバックエンドを使用することで3秒に短縮できます。

ここで、配列ライブラリによる範囲チェックは本当に痛いです。チェックされていない配列アクセスを使用すると(バグをチェックした後、ループを制御するコードでチェックがすでに行われているため)、GHCのネイティブバックエンドは2.4秒で終了し、llvmバックエンドを経由すると1.55秒で計算が終了します。これはまともですが、CとJavaの両方よりも大幅に低速です。配列GHC.Primライブラリの代わりにからのプリミティブを使用して、llvmバックエンドは1.16秒で実行されるコードを生成します(ここでも、各アクセスで境界チェックは行われませんが、計算中に有効なインデックスのみが生成されることは、この場合、前に簡単に証明できます。したがって、ここでは、メモリの安全性が犠牲になることはありません¹。各アクセスをチェックすると、最大1.96秒の時間がかかります。これは、アレイの境界チェックよりもはるかに優れています。図書館)。

結論:GHCは境界チェックのために(はるかに)高速な分岐を必要とし、オプティマイザーには改善の余地がありますが、原則として、「Haskellのアプローチ(型システムでエンコードされた純度)は、効率、メモリ安全性、および単純性と互換性があります「、私たちはまだそこにいません。当面は、どのポイントをどれだけ犠牲にしても構わないと思っているかを決める必要があります。


¹はい、これは特殊なケースです。一般に、境界チェックを省略すると、メモリの安全性が犠牲になります。そうでないことを証明するのは、少なくとも困難です。

于 2012-08-02T12:01:55.950 に答える
12

この問題を攻撃するための2つの角度があります。

  1. これらの方針に沿って、研究が進行中です。今、私より賢いHaskellプログラマーはたくさんいます。私は常に思い出し、謙虚になっているという事実。そのうちの1つがやって来て私を訂正するかもしれませんが、安全なHaskellプリミティブを最上位の行列乗算ルーチンに構成する簡単な方法を私は知りません。あなたが話しているそれらの論文は良いスタートのように聞こえます。

    しかし、私はコンピュータサイエンスの研究者ではありません。Haskellでは単純なことを単純に保つことができるのだろうか。

    それらの論文を引用すれば、おそらく私たちはそれらを解読するのを手伝うことができます。

  2. これらの方針に沿ったソフトウェアエンジニアリングは、よく理解されており、簡単で、さらには簡単です。精通したHaskellコーダーは、BLASの周りに薄いラッパーを使用するか、Hackageでそのようなラッパーを探します。

最先端の研究を解読することは、知識を研究者からエンジニアに移す継続的なプロセスです。クイックソートを最初に発見し、それに関する論文を発表したのは、コンピューターサイエンスの研究者であるCARHoareでした。今日、メモリからクイックソートを個人的に実装できないのは、まれなコンピュータサイエンスの卒業生です(少なくとも、最近卒業した人たち)。

ちょっとした歴史

ほぼこの正確な質問は、歴史の中で数回前に尋ねられました。

  1. アセンブリと同じくらい速いFortranで行列演算を書くことは可能ですか?

  2. Fortranと同じくらい速いCで行列演算を書くことは可能ですか?

  3. Cと同じくらい速いJavaで行列演算を書くことは可能ですか?

  4. Javaと同じくらい速いHaskellで行列演算を書くことは可能ですか?

これまでのところ、答えは常に「まだ」であり、その後に「十分に近い」が続きます。これを可能にする進歩は、コードの記述の改善、コンパイラーの改善、およびプログラミング言語自体の改善からもたらされます。

具体的な例として、過去10年間にC99コンパイラーが普及するまで、Cは多くの実際のアプリケーションでFortranを超えることができませんでした。Fortranでは、異なる配列は互いに別個のストレージを持っていると想定されますが、Cでは一般的にそうではありません。したがって、Fortranコンパイラーは、Cコンパイラーでは不可能だった最適化を行うことが許可されていました。ええと、C99が出てrestrict、コードに修飾子を追加できるようになるまでは。

Fortranコンパイラは待機していました。最終的に、プロセッサは十分に複雑になり、適切なアセンブリの記述がより困難になり、コンパイラは、Fortranが高速になるほど高度になりました。

その後、Cプログラマーは、Fortranに一致するコードを記述できるようになるまで2000年代まで待ちました。その時点まで、彼らはFortranまたはアセンブラー(あるいはその両方)で書かれたライブラリーを使用するか、速度を落として我慢していました。

同様に、Javaプログラマーは、JITコンパイラーを待たなければならず、特定の最適化が表示されるのを待たなければなりませんでした。JITコンパイラは、日常生活の一部になるまで、もともとは秘教的な研究概念でした。すべてのアレイアクセスのテストと分岐を回避するために、境界チェックの最適化も必要でした。

Haskellに戻る

したがって、Haskellプログラマーが「待っている」ことは明らかです。Java、C、およびFortranプログラマーが彼らの前にいるのと同じです。私たちは何をぐずぐずしているんですか?

  • たぶん、誰かがコードを書いて、それがどのように行われるかを見せてくれるのを待っているだけなのかもしれません。

  • たぶん、コンパイラが良くなるのを待っています。

  • たぶん、Haskell言語自体のアップデートを待っています。

そして多分私達は上記のいくつかの組み合わせを待っています。

純度について

Haskellでは純度とモナドがかなり混同されます。これは、Haskellでは不純な関数が常にIOモナドを使用するためです。たとえば、Stateモナドは100%純粋です。したがって、「純粋」と「型署名はモナドを使用しない」と言う場合State、これらは実際には完全に独立した別個の要件です。

ただし、純粋関数の実装でモナドを使用することもできますIO。実際、これは非常に簡単です。

addSix :: Int -> Int
addSix n = unsafePerformIO $ return (n + 6)

はい、それはばかげた機能ですが、それは純粋です。それは明らかに純粋です。純度のテストは2つあります。

  1. 同じ入力に対して同じ結果が得られますか?はい。

  2. 意味的に重要な副作用が発生しますか?いいえ。

純粋が好きな理由は、純粋関数は不純な関数よりも構成と操作が簡単だからです。それらがどのように実装されるかはそれほど重要ではありません。あなたがこれを知っているかどうかはわかりませんが、インターフェースが純粋であっIntegerByteStringも、どちらも基本的に不純なC関数のラッパーです。(の新しい実装に関する作業がありますがInteger、それがどこまであるかはわかりません。)

最終的な答え

問題は、Haskellのアプローチ(型システムでエンコードされた純度)が効率、メモリの安全性、および単純さと互換性があるかどうかです。

その部分に対する答えは「はい」です。BLASから単純な関数を取得して、それらを純粋でタイプセーフなラッパーに入れることができるからです。Haskellコンパイラが関数の実装が純粋であることを証明できない場合でも、ラッパーの型は関数の安全性をエンコードします。その実装での使用はunsafePerformIO、関数の純粋さを証明したことの承認であり、Haskellの型システムでその証明を表現する方法を理解できなかったことも譲歩です。

しかし、Haskell自体で関数を完全に実装する方法がわからないため、答えも「まだ」ではありません。

この分野の研究は進行中です。人々は、Coqのような証明システムやAgdaのような新しい言語、そしてGHC自体の開発に注目しています。どのような型システムであるかを確認するには、高性能のBLASルーチンを安全に使用できることを証明する必要があります。これらのツールは、Javaなどの他の言語でも使用できます。たとえば、Java実装が純粋であることをCoqで証明することができます。

「はい」と「いいえ」の回答をお詫びしますが、エンジニア(「はい」を気にする)と研究者(「まだ」を気にする)の両方の貢献を認める回答は他にありません。

PS論文を引用してください。

于 2012-08-02T03:36:29.727 に答える
8

Javaのように、Haskellは数値コードを書くのに最適な言語ではありません。

Haskellの数値が多いコード生成は...平均的です。IntelやGCCのようなものが持っているような、その背後にある長年の研究はありませんでした。

Haskellが代わりに提供するのは、「高速」コードをアプリケーションの他の部分とクリーンにインターフェースする方法です。コードの3%が、アプリケーションの実行時間の97%を占めていることを忘れないでください。1

Haskellを使用すると、これらの高度に最適化された関数を、コードの他の部分と非常にうまくインターフェースする方法で呼び出すことができます。つまり、非常に優れたC外部関数インターフェースを使用します。実際、必要に応じて、アーキテクチャのアセンブリ言語で数値コードを記述し、さらにパフォーマンスを向上させることができます。アプリケーションのパフォーマンスが高い部分をCに浸すことはバグではなく、機能です。

しかし、私は逸脱します。

これらの高度に最適化された関数を分離し、Haskellコードの他の部分と同様のインターフェイスを使用することで、Haskellの非常に強力な書き換えルールを使用して高レベルの最適化を実行できます。これによりreverse . reverse == id、コンパイル時に複雑な式を自動的に削減するようなルールを記述できます。 2これにより、 Data.Text3やData.Vector[4]のような非常に高速で、純粋に機能的で、使いやすいライブラリが実現します。

高レベルと低レベルの最適化を組み合わせることで、それぞれの半分( "C / asm"と"Haskell")が比較的読みやすくなり、はるかに最適化された実装になります。低レベルの最適化はネイティブ言語(Cまたはアセンブリ)で行われ、高レベルの最適化は特別なDSL(Haskell書き換えルール)を取得し、残りのコードは完全にそれを認識しません。

結論として、はい、HaskellはJavaよりも高速である可能性があります。しかし、それは生のFLOPSのためにCを通過することによって不正行為をします。これはJavaで行うのがはるかに難しいため(JavaのFFIのオーバーヘッドがはるかに高くなるため)、回避されます。Haskellではそれは自然なことです。アプリケーションが数値計算に途方もない時間を費やしている場合は、HaskellやJavaを調べる代わりに、必要に応じてFortranを調べます。アプリケーションがパフォーマンスに敏感なコードのごく一部に時間の大部分を費やしている場合は、HaskellFFIが最善の策です。アプリケーションが数値コードに時間を費やさない場合は、好きなものを使用してください。=)

Haskell(さらに言えばJavaも)はFortranではありません。

1これらの数字は構成されていますが、あなたは私の主張を理解しています。

2 http://www.cse.unsw.edu.au/~dons/papers/CLS07.html

3 http://hackage.haskell.org/package/text

[4] http://hackage.haskell.org/package/vector


これで邪魔にならないので、実際の質問に答えます。

いいえ、現在、Haskellで行列の乗算を書くのは賢明ではありません。現時点では、REPAはこれを行うための標準的な方法です[5]。実装はメモリの安全性を部分的に破壊しますが(unsafeSliceを使用します)、「破壊されたメモリの安全性」はその機能に限定され、実際には非常に安全であり(ただし、コンパイラによって簡単に検証されません)、問題が発生した場合は簡単に削除できます(「 unsafeSlice"と"slice")。

しかし、これはHaskellです!関数のパフォーマンス特性が単独で取得されることはめったにありません。それは悪いこと(スペースリークの場合)、または非常に非常に良いことです。

使用される行列乗算アルゴリズムは単純ですが、生のベンチマークではパフォーマンスが低下します。しかし、コードがベンチマークのように見えることはめったにありません。

あなたが何百万ものデータポイントを持つ科学者であり、巨大な行列を乗算したい場合はどうでしょうか?[7]

それらの人々のために、mmultP[6]があります。これは行列の乗算を実行しますが、データは並列であり、REPAのネストされたデータの並列性の影響を受けます。また、コードは基本的にシーケンシャルバージョンから変更されていないことに注意してください。

巨大な行列を乗算せず、代わりに多くの小さな行列を乗算する人々にとって、上記の行列と相互作用する他のコードが存在する傾向があります。おそらくそれを列ベクトルに分割してそれらの内積を見つけ、おそらくその固有値を見つけ、おそらく完全に何か他のものを見つけます。Cとは異なり、Haskellは、問題を個別に解決したいのに、通常、最も効率的な解決策がそこにないことを知っています。

ByteString、Text、Vectorと同様に、REPA配列は融合の対象となります。2ちなみに、実際には2を読む必要があります。これは非常によく書かれた論文です。これに、関連するコードの積極的なインライン化とREPAの高度な並列性を組み合わせることで、これらの高レベルの数学的概念を、舞台裏で非常に高度な高レベルの最適化で表現できます。

純粋関数型言語で効率的な行列乗算を記述する方法は現在わかっていませんが、ある程度近づけることはできます(自動ベクトル化なし、実際のデータに到達するためのいくつかの過度の逆参照など)が、IFORTまたはGCCが実行できます。しかし、プログラムは島に存在しません。島全体のパフォーマンスを向上させることは、JavaよりもHaskellの方がはるかに簡単です。

[5] http://hackage.haskell.org/packages/archive/repa-algorithms/3.2.1.1/doc/html/src/Data-Array-Repa-Algorithms-Matrix.html#mmultS

[6] http://hackage.haskell.org/packages/archive/repa-algorithms/3.2.1.1/doc/html/src/Data-Array-Repa-Algorithms-Matrix.html#mmultP

[7]実際、これを行う最良の方法は、GPUを使用することです。Haskellで利用できるGPUDSLがいくつかあり、これをネイティブに実行できます。彼らは本当にきれいです!

于 2012-08-02T04:47:33.283 に答える