LED通信事業プロジェクト エンジニアブログ
F#で行列計算をしよう #6
CPU使用率を上げる
記事更新日 2025年4月22日
はじめに
Microsoft F#は、Microsoft .NETグループの一言語であり、.NETで唯一の関数型言語です。関数型言語は副作用が少なく、その結果、完成したコードのエラーが少ないというメリットがありますが、それ以外にもう一つメリットがあります。それは、関数型言語は「行列計算を駆使したDeep Learning系AI処理を書きやすい」ということです。
しかし、F#で行列計算をしようと思っても、Microsoftの公式リファレンス程度しか見当たらず、しかも、それすらまともな解説も使用例もないため、折角の高機能が活かされていません。調べても誰も書いている様子がありません。仕方なく私が作るしかないと考え、F#で行列の計算をする方法を何回かに分けた連載という形でお伝えしようと思います。
真面目に最初から読むというよりは、F#を調べているときに検索に引っかかれば良い、という程度の記事にしていくつもりです。なぜなら、このようなニッチな内容は、AIでは「正しいか正しくないか」の判断が難しいことが多いため、このような記事もあれば、誰かの役に立つのではないかと考えています。
ちなみに、AIそのものの作成方法(誤差逆伝播法など)や、行列計算そのものの意味(意味や計算方法など)、そういったものの説明はしません。そもそも、そのような大層な内容ではなく、あくまで「プログラミング中に困って検索したら引っかかる」ことを目的としています。
前回は、内積の計算方法を紹介しました。内積の計算はもともと時間のかかる計算ではあるのですが、標準のF#(外部ライブラリを使わない)で計算式を組んでしまうとCPUを効率良く使えず、本来の計算性能が活かせないという話でした。今回は、標準F#でCPUの使用率を上げて効率良く使う方法を考えていきたいと思います。
前回のちょっとしたおさらいと今回の方針
F#には、標準で内積を計算してくれる関数は存在しないため、AIで多用される内積を計算するためには自分で関数を作る必要があります。内積の計算方法として、これまでの経験から「スライス+MAP2」という方法を編み出したのですが、(私の環境だと)CPUを20%ぐらいしか使ってくれません。一方、PythonのNumPyを.NET用に移植したプラグインである"NumPyDotNet(NumPy.net)"を使って計算すると、CPUを65%程度まで使ってくれるため、「スライス+MAP2」よりかなり高速に計算が出来きました。
今回は、打倒"NumPy.net"ということで、「スライス+MAP2」方式を使った上で、CPU利用率を上げるコードに変更して、NumPy.netよりも速く計算する、ということを目指したいと思います。
尚、ハードウエアの性能に関する話なので、最初に実行環境を説明しておきます。実行するのは、自分がこの記事を書いているノートパソコンとなります。
実行環境
- CPU: Ryzen7pro 4750U
- 基本1.7GHz 最大4.1GHz
- 8コア16スレッド
- メモリ: 16GB
- OS: Windows10 64bit
前回までの計算は、F#インタラクティブ※1上で計算していましたが、今回はF#インタラクティブでは動かないので、コンパイルしてから実行になります。
F#でCPU使用率を上げる方法
CPUの使用率を上げると言っても、実はF#を含むMicrosoft.NETにおいてCPUをどう使うかコントロールする機能は存在しません。一方、C言語のような機械語に近い言語では、使用するCPUのスレッドを直接指定することもできるため、CPU使用率はかなり細かいところまでコントロール出来ます。ただし、どんな言語を使おうともCPUをいつ、どう使うかは最終的に"OS"が判断しますので、アプリケーション側から完全にはコントロールは出来ません。こう書くと、CPUを制御できないことが不便なように聞こえちゃいますが、通常使用においてはCPUをどう使用するかなんてことを気にすることの方が不便です。なので、一般的にはCPUの存在が見えなければ見えないほど「高級な」言語であり、良いOSなんですけどね。
さて、F#でCPUの使用率は制御できないといっても、CPUの負荷を上げる”ような”コードの書き方というのは存在します。その一つが、メモリ、ストレージへのアクセスを最小にし、CPUの使用、つまり計算だけに集中させること。メモリやストレージ(ファイル)といった外部装置へのアクセスには時間がかかります。計算途中にそういった動作が入ると、そこがボトルネックとなりCPUの待ち時間(遊び時間)が出来てしまいます。CPUを遊ばせるような動作を減らす、それがCPUを効率良く動作させるポイントです。
そして、もっともCPU使用率に寄与するコードの書き方が、複数の計算プロセスを同時に動かすことです。マルチスレッド処理とも呼ばれます。仮に1つの計算ではCPUを使い切るのが難しくても、複数の計算を同時に行うことで、結果的にCPUの負荷は上げることが出来る、そういう考えに基づきます。この方法自体は昔からありますが、現在のマルチコア化したCPUにこそ適した方法だと言えます。
今回の内積計算は、コードとしては単純で、メモリへのアクセスも最小限です。つまり、これ以上のメモリアクセスの工夫は難しいコードです。したがって、CPU使用率向上には、マルチスレッドが良いでしょう。内積の計算を、複数同時に動かして、結果的にCPU使用率を上げるのです。
というわけで、マルチスレッドを使って、標準F#でNumPy.netに対抗できるか、いろいろと試してみる、というのが今回の趣旨です。
同期処理と非同期処理
マルチスレッドの前に、プログラミングの基本を少し説明します。
通常のプログラミングは同期処理で実行されます。ちょっとわかりにくいので言い直しますが、プログラミングの同期処理というのは、1行ずつ順番に、シーケンシャルに処理することを言います。これは、C#であろうと、Pythonであろうと、JavaScriptであろうと、どんな言語でも同じです。普通に書けば上から順番に、一行ずつコードが処理されていきます。もちろん、上に飛んだりループしたりはありますが、基本的にコードが指定した順番通りに一行ずつ処理が行われていきます。この同期処理の場合、前の処理が終わらないと、次の処理には絶対に進みません。
一方、非同期処理というのは、前の処理が終わったかどうかに関係無く、次の処理に進むことです。時間がかかる処理などを非同期で実行しつつ次の処理を進める、というのがよくある使われ方です。例えば、皆さんが最もよく見るであろう非同期処理が、ブラウザの「データを読み込みつつ、画面表示する」という動作です。もし、ブラウザの表示を同期処理で行えば、データを全て読み込んで、読み込み終わったら画面が表示される、という処理になります。しかし、ブラウザの表示は非同期処理なので、データを読み込みつつ、読み込めた部分から画面に表示してしまっています。もちろん、ブラウザ以外でも、時間のかかるデータ読み込みと他の処理を非同期にするのはよくある手法です。大概の処理はCPUやメモリを100%使うわけではありませんから、時間のかかる処理は非同期にして別の処理と同時に動かした方が、結果的に全体の処理が速くなるというのは、もはや言うまでもないと思います。
今回の「内積の計算を複数同時に動かす」というのも、まさに非同期処理です。内積計算は重い処理ですが、それでも20%しかCPUを使わないのであれば、少なくとも4つまでは非同期で同時に動かしても遅くならないはずですよね?もし、それが可能ならば、下図のように内積計算もかなり高速化できるのではないか?というのが今回の試みになります。このように、複数の処理を同時に動かすことをマルチスレッドと呼びますが、マルチスレッド=非同期処理だと考えてくれて問題ありません。
F#の非同期処理
非同期処理は、同期処理と比べて考慮することが多くなるので、コードが複雑になるのは間違いありません。言語によっては、非同期の記述方法が非常にわずらわしい場合もあります。それどころか、古い言語だと、非同期処理がまったく出来ない場合すらあります(例えばVB6/VBA)。F#は比較的新しい言語のため、非同期処理が容易に記述できるように設計されています。それは、F#と同じ.NET仲間であるC#やVB.netに比べても、かなり簡単と言えるものです。
F#の非同期コードを書くにはいくつか方法があるのですが、計算速度に違いがあるわけじゃないので、一番簡単な方法でコードを書きます。非同期化したい処理をasync{}
の中に入れる、以上です。どうです、簡単でしょ?実際のコード例は、こんな感じです。
/// 非同期関数の例
async{
Threading.Thread.Sleep(2000) //2秒待つ
Console.WriteLine("非同期メッセージ!")
} |> Async.Start //非同期処理スタート
Console.WriteLine("同期メッセージ!") //こちらは同期なので即実行
【出力】 同期メッセージ! 非同期メッセージ!
このコードを上から実行していくと、まず最初にasync{}
が実行されます。しかし、async{}
の括弧の中の処理群は非同期処理になっています。非同期ということは、処理の終了まで待たないと言うことですから、async{}
の開始後、中の処理がどうであろうと関係無く、すぐに次の行(7行目)が実行され、「"同期メッセージ!”を表示」が実行されます。そのため、このコードを実行すると、まず最初に"同期メッセージ!"と表示されます。
一方、async{}
の中は同期式です。そのため、2秒待ってから「"非同期メッセージ!"を表示」という処理を行います。つまり、同期で行われた最初のメッセージ表示から、約2秒後に”非同期メッセージ!"が表示されます。
内積計算の非同期化
それでは、本題の内積計算を非同期化する方法を考えてみます。先ほどは、非同期処理の良い部分だけを書きましたが、当然、非同期処理の面倒な部分もあります。それが、答えがいつ帰ってくるかを制御しにくいということ。先ほどの例の「メッセージを表示」とか、「UIの更新」とかの画面表示更新は良いんですよ、非同期で。それが、いつ終わろうとも、アプリケーション全体の動作に関係無いから。画面を更新時間と、裏で回っている計算処理に関係はありませんから。
でも、内積の計算、みたいな次の計算のための処理で、これが終わらないと次にいけない、となると話は違ってきます。そういった場合、非同期で処理を回していても、最終的には同期処理的に、全ての処理の完了を待って次のステップに行く必要があります。つまり、全ての非同期処理が終わってから、次の処理に進まなければいけません。それじゃ、同期処理するしかないとお考えの皆様。ご安心下さい。非同期処理をサポートしている言語であれば、おおよそこういった処理も想定されていて、専用の関数があります。
F#においても、非同期処理時に「複数計算を同時に行っても、計算結果が揃ってから次に行く」といった処理を実行する関数があります。それが、Async.Parallel
です。「Parallel」という単語が入っているからもわかるとおり、複数処理を同時に(非同期に)行いますが、その計算結果がすべてそろったら、次の処理に渡す、そういった動作をします。非同期で行いたい処理はそれぞれリスト(配列に近いが、配列とは異なる)形式に入れておくことで、計算非同期に、しかし結果は同期的に取得できる、というわけです。計算する処理をリスト化するところは、変数も関数も同じ扱いのF#ならではの実装方式ですね。
このAsync.Parallel
処理方式、まさに内積計算のような、重い計算を非同期並列で行うのに準備された方法のようです。これは使うしかない!前回の内積サンプル2"スライス+MAP2"関数を、Async.Parallel
で使えるようにするため、まずは1つの内積処理を非同期化してみます。
/// 内積サンプル2(前回紹介したものと全く同じコードです)
/// スライス+MAP2
let dotTest2 (ary1:double[,]) (ary2:double[,]) =
let maxx = Array2D.length1 ary1 //配列の長さを求める(正方行列のみ)
let ansAry = Array2D.create maxx maxx 0.0 //結果入力用の配列
// かけ算を行う関数(map関数で使用)
let multi x y = x * y
for y in [0 .. maxx - 1] do
let rowAry = ary1[y, *] //ary1の1行をスライス
for x in [0 .. maxx - 1] do
let newAry = Array.map2 multi rowAry (ary2[*,x]) //各要素をかけ算
let elem = Array.sum newAry //全要素を足し算
ansAry[y, x] <- elem
ansAry //戻り値
/// 内積非同期計算用
/// 上記の内積サンプル2(スライス+MAP2)を非同期化して繰り返し計算
let asyncDotTest2 (ary1:double[,]) (ary2:double[,]) testCount =
// 結果となる配列のサイズを取得
let sizeRow = Array2D.length1 ary1
let sizeCol = Array2D.length2 ary1
async{
//計算結果を戻すための配列を定義
let mutable rst = Array2D.create sizeRow sizeCol 0.0
// dotTest2を使って内積計算を繰り返す
for i in [1 .. testCount] do
rst <- dotTest2 (ary1:double[,]) (ary2:double[,])
return rst
}
上のdotTest2
は前回紹介した(同期処理)の内積計算用関数です。これを、下のasyncDotTest2
という関数にて、async
で囲うことで非同期で動作するようにしています。この非同期範囲の中で、指示された回数だけ内積計算を繰り返すようになっています。通常、同じ内積計算を繰り返しても意味はありませんが、計算速度テスト用なので、無駄に繰り返しています。async
内の計算結果の戻り値はrst
という値です。F#の通常の関数では、戻り値を指定する必要は無い(自動で決定する)のですが、async
内では、戻り値をreturn
で特定させます。尚、この例では同じ計算を繰り返しているだけですので、(意味の無い)戻り値は最後の計算結果としています。
さて、この関数をリストにして、パラレルで動かすために、以下のコードを準備します。
/// Async.Parallelを実行するコード
let asyncDotTest2Main (ary1:double[,]) (ary2:double[,]) testCount split =
// 計算を複数同時にするために式の配列を作成
// Arrayではなく、Listが必要なので、手動で数を増やす
let dotList =
[
// 分割数だけ同じ行を記述
asyncDotTest2 ary1 ary2 (testCount / split)
asyncDotTest2 ary1 ary2 (testCount / split)
asyncDotTest2 ary1 ary2 (testCount / split)
asyncDotTest2 ary1 ary2 (testCount / split)
// 以下に追加
// asyncDotTest2 ary1 ary2 (testCount / split)
]
// 関数リストをAsync.Parallelにセット
let asyncList = Async.Parallel dotList
// 計算実行
let asyncRst = Async.RunSynchronously asyncList
asyncRst //戻り値
この関数は、以下の構成となっています。
- パラレルに動かすための関数のリストを作成(結果:非同期関数のリスト)
- リストを
Async.Parallel
にセット(結果:非同期関数の配列) - 関数リストを一斉に非同期で実行(結果:2次元配列を要素とする1次元配列)
まず、最初に非同期で動かすための関数のリストを作成します。リスト、というのがポイントです。配列(array)だと正常に動かないのです。リストは配列と異なり自由度が低いため、宣言方法が制限されます。だから、ちょっと見苦しいですが、宣言が手書きになっています。
次に、リストをAsync.Parallel
にセットします。これにより、リスト化された関数の出力は、パラレルに非同期実行されるようになります。ただし、この段階では、パラレル化されただけで、まだ計算は実行されていません。
最後に、パラレルのリストをAsync.RunSynchronously
で実行します。非同期の最初の例ででてきたAsync.Start
と同じ役目ですが、こちらは「複数の非同期処理を一斉に開始する」という意味を持ちます。そして、Async.RunSynchronously
の戻り値は計算結果となります。この計算結果は、リストの各計算の答えが配列なって返ってきます。つまり、この結果の配列が戻ってくるまで、この行の処理は終わらない、という、言わば「同期」処理となります。
以上のようにして、計算結果が必要な非同期処理は行われます。
計算結果
今回の目標は、NumPy.netの内積計算にF#の計算だけで近づけることです。ただ、非同期処理をどれぐらいパラレル、すなわちスレッドの並列化で処理させると高速になるか?というのはやってみないとわかりません。そのため、配列サイズを変えて、いくつか試してみました。
まず、回数別の結果は以下の通り。
実験前に私が色々調べた感じでは、一般的に並列化はCPUのコア数ぐらいがベストであり、それ以上の並列化は無駄になる場合が多いようです。今回使うCPUはAMDの8コアなので、8スレッドぐらい並列化にすると高速になるのではないかと予想していました。しかし、どうやら、実験の結果スレッド数(並列数)は4が最も高速になるようです。結果を見れば、配列のサイズを変えても、並列数と計算時間の関係はほぼ同じであることがおわかり頂けると思います。そういえば、同期処理の場合のCPU使用率が20%程度だったので、4倍すれば丁度良い感じになりますし、これは納得。
さて、この最速4並列を使用して、NumPy.netと闘ってみたいと思います。以下が計算結果です。1番上は、従来の非同期、真ん中が今回の並列化、一番下がNumPy.netです。
行列サイズ 計算回数 |
16x16 100,000 |
64x64 10,000 |
128x128 200 |
256x256 500 |
512x512 100 |
1024x1024 4 |
---|---|---|---|---|---|---|
同期処理 | 3,061 | 14,753 | 22,953 | 48,631 | 99,150 | 81,396 |
非同期 並列x4 | 1,472 | 7,355 | 12,687 | 27,507 | 60,468 | 38,846 |
NumPy.net | 15,186 | 14,420 | 13,043 | 14,117 | 22,158 | 18,296 |
あれ?速くはなっているけど、そこまで速くなっていないような?結局、配列のサイズ256以上の場合、NumPy.netに完全に負けているし。そもそも、同期処理の4倍速どころか、倍速程度しか速度が出ていない。
どういうことだ?
よくよく調べてみると、CPU使用率は4並列でも、40%程度で止まっているじゃないか!処理数4倍なので80%ぐらいの使用率を想定していたのに。仕方が無いので、並列化数とCPU使用率更に調べてみました。ちなみに、CPU使用率はWindowsのタスクマネージャー目視なので、おおよその値としてご認識下さい。
並列数 | CPU使用率[%] |
---|---|
1 | 20 |
2 | 31 |
4 | 42 |
8 | 51 |
10 | 55 |
20 | 68 |
あれ?確かに、並列数を増やせば、CPU使用率は上がっていってはいます。しかし、前述の通り、並列数は4の時が処理としては最速であり、それ以上は並列数を増やせば増やすほど、処理時間は長くなっていきます。うーん、なぜだかわかりませんが、並列を増やせばCPU使用率は上がるのに、処理時間は短くならないということのようです。行列サイズを大きくすると計算時間が極端に長くなることを考えると、もしかしたら、ボトルネックとなっているのはキャッシュとかメモリとかかもしれません。いずれにしても、生のF#でこれ以上の高速化は難しいようです。
ちなみに、試行回数で並列するから速度が遅くなるのかと思い、1つの内積の計算を分散させる(スライスの行毎に分割して内積計算する)といったこともやってみたのですが、並列数が4で最速となるのも、その時のCPU使用率が40%程度になるのも、変わりませんでした。
まとめ
2回にわたって内積を計算してきましたが、最終的な結論は比較的シンプルです。一定以上サイズの行列の内積計算をする場合、F#単体では力不足です。非常に残念ながらAI向けの内積を計算するならNumPyDotNetを使った方がよいということになります。
もっと言えば、NumPyだとしてもCPUで行列計算をするのは得策ではない、かもしれません。今回配列要素として、一番重い倍精度浮動小数点(Double)の行列を使いましたが、それだとしても2048x2048の配列の内積計算で20秒近くかかってしまうというのは遅すぎます。もっと、高速に計算する必要があるのでしょう。
そうなると、更なる高速化のためには、やっぱりGPUを使うしかしかないのか?というわけで、次回は、F#とGPUを使って内積を計算をする方法を考えたいと思います。
それでは、次回をお楽しみに!!
※1; F#のコードを対話形式で実行する環境。コードをコンパイルせずに直接実行出来るため、コードの試験等に最適。