高速化の歴史

Yukio Okuda氏(以下、Okuda):みなさんこんにちは。私はフリーランスのOkudaでございます。フリーランスと言っても、かたちばかりでお客さまがついたことはございません。

ですので、これは実際に使って云々の話ではございませんので、実際お使いになっている方から見ると、おかしなところがあると思います。そういったことはビシバシ指摘していただければと思います。

カンファレンスが終わりまして、このアカウントは昨日作ったアカウントですので、ここに投げていただければ、すぐ応答したいと思っております。そうした中で思い出してみると、CPUというのは学生の時代からずっと使ってるわけです。今後も使っていくと思います。

私はただのアプリケーションエンジニアだったので、CPUのアーキテクチャがどうなっているか気にしてコーディングしたことはありません。気にしたのはメモリサイズ、それとCPUのスピードなわけです。

ですから、90年の中頃に、あのものすごいスピードアップが起きたときには、本当にどうなることかと思いました。それも止まってしまって、Pentium4からハイパースレッディングが出たわけです。

これで、アーキテクチャを意識するという時代がくるのかと思ったんですが、ハイパースレッディングとはマルチCPUでもユーザーがコントロールできないわけです。

インテルは64bitが終わったあとに、ユーザーがコントロールできるマルチCPU、すなわちマルチコアを出してきたわけです。そのコア数は、今では24個。みなさまお持ちのハンドヘルドに入ってるARMもクアッドコアが当たり前の時代です。

ですから、今「コアと言えばCPUだ」と言うのは一般常識になっています。でも、ちょっと昔は「コアというのはメモリだよ」という専門家の用語だったわけです。

そういう時代にN-Threadsのプログラムを組んでも、1-Threadより早くならない。じゃあ、どうやってCPU-Boundを高速にするかとなったときに、非常に高価なベクトルプロセッサが必要だったわけです。

そんなものを扱えるのは一部の人です。ですから、ほんの限られた人のアプリケーションでした。今はどうでしょう。N-ThreadsをM-Coreで動かすと、だいたいミニマムのN,M分ぐらいは出そうだねと。

数千万円したベクトルプロセッサが3~4万円のものが家庭用に入っちゃうわけです。私、現役時代にベクトルプロセッサを使いたかったんですが、まったく使えませんでした。

じゃあ、自分の家に入れて使ってみようよと思ってソフトウェアを見たときに、ハードほど簡単じゃないんです。先ほど1-CPUでN-Threadというのは効果がないと申し上げましたが、それはCPU-Boundの話です。

I/O Boundではものすごいパワーがあるんです。それを知ったのは70年代にIBMがオンラインに乗り出したときです。システムで言いますとブロックというのがあったから、彼らはプアなCPUで銀行のオンラインができたわけです。

これは今でも最新のハードで動くそうです。ですが、あまりに特殊なスレッドのために、標準には全然ならなかった。一方、プロセスのほうはSUNがNFSとかRPCによって、サププロセスを確立した。

これはずっと使われて、今もスタンダードになっているわけです。ところが、スレッドを見たときに今の標準のPOSIXのスレッドというのは、インプリメントされたのがすごく遅くて、マルチコアが始まった頃なわけです。

じゃあ、アプリケーションはみんな走ったでしょうか。スワップ起きているときに何バカなこと言うかという話になるわけです。本当に使えるようになったのは、アプリケーションのサイズとか環境によって違いますが、こういうスパンで見ると、ついこのあいだなわけです。

高速化するための2つの考え方

Pythonを見たときに、PythonにはGILの問題があります。これは、N-ThreadをM-Coreで動かしても、1-Coreしか動いてくれない。ひと昔前なんです。

じゃあ、そうした中でどうやって高速化するか。2つの考え方があります。スピードはもう命よと。いやいや、そんなこと言わないで早く作ってよと。

環境もどんどん変わるんだからコーティングしてよ。このどっちを取るか。バランスが難しいところだと思うんです。インタープリタなんか使うから遅いんだよ。じゃあコンパイラ使いなよと今さら言われても。

じゃあ、GILやめたら? GILいらないインタープリタを作ってあげるから。だけど、それって自分の欲しいパッケージはそこにコーティングされてるでしょうか?

よく見ると、言語もそんなにCompatibleじゃない。というわけで、じゃあ必要な部分だけコンパイリングしてあげるというパッケージが出てきたわけです。

だけど、それは本当にCompatibilityがあるのか、Portableなのか。スピードはどうなのか。やっぱりそのへんのバランスを考えてレビューしました。

今日の資料は後ほど公開するので、どうやって使うかという話はしません。コードを知りたい方は付録にあげておりましたので、リンクをポチればそこにいきます。

私の話を聞かなくても、外部リンクが優秀なのがありますから、外部リンクも貼っておきました。どうやってスピードを比較するか。どのぐらいのスピードが対象になっているかというところをお話します。

モンテカルロ法で円周率を計算する

今回の企画は、モンテカルロで円周率を計算する。 要するに乱数を使ってπを計算します。そのときに乱数の数をいくつかポイントをとっていきます。そして計算方式を変えて、データをカーブフィッティングにかけて、直線の傾きを出していきます。

加速する時のもとのプログラムとして、普通のPythonとNumPyをベースにするという話があります。今日の最初のお話の時にありましたように、やっぱりNumPyが1つの基準になっているわけです。

グリーンは、これをCPUで動かした場合。ティピカルなのはNumbaというパッケージは、デコレーションを1ライン入れると、20倍にしてくれます。それを自分でスレッディングして、4個並列で走らせると、その4倍で80倍になります。

かたやNumPyは、今日のお話があったように、マシンラーニング関係のパッケージで、そのままコードを変換してやると。そうすると、一番速いもので、PyTorchとかCuPyで500倍ぐらいのスピードが出るというのが、今日の大きなお話になります。

計測方法

どうやって計るか。

マシンを2つでやっています。サーバーは今回のプロジェクトのために新規にスクラッチからつくりました。Condaの環境にTensorFlow以外はそこに入れてあります。

そして別のマシンからSSLハッチで入って、実行するのはPython。IPythonとかオーバーヘッドの大きなものはやらない。シェルスクリプトでくるんで、バッチで何度も再現できるように、ログも解けるようにして実行します。

ハードはSandy Bridgeの少し古いもの。2.0GHzの4Core、8HTになります。ここで1つポイントは、バイオスで2つオフっています。これをオンにしていますと、負荷がないと眠っているわけです、CPUは。

そしてある程度上がってきて、スレッショルドを超えると、この場合1個のCoreだけクロックを上げますので、スピードはばらついてしまいます。そのハードにCUDAの一番安いものをつけてあります。

バックグラウンドについてお話しいたします。

先ほどのOneThreadでしか動かないという話なんですが、それを見てみるわけです。ローカル変数に1をn回足す、これを2回実行します。

一番上は通常のやり方、真ん中はParallelなプロセスでやる。下はスレッドです。

これで様子を見ると、スレッドはまったく同じスピードになります。

先ほどのターボをオンにすると、25パーセントダウンしてしまいます。Processでやったほうは、ご覧のようにLaunchで大きなオーバーヘッドがかかりますので、トータル的に2倍は出ないと。

これがやっぱりProcessというのはオーバーヘッドが大きいから、CPU boundやっぱりスレッドですねという1つの根拠になるわけです。

スレッドセーフかどうか

今度はスレッドセーフになっているかどうか。グローバル変数に1をn回足します。n回引きます。それをスレッドでやります。

グローバル変数で同じことをプラスマイナスしますから、結果は0になっているはず。1,000回分ためておいて、その中で0じゃないものをカウントします。

そうすると、この赤がそうなんですが、最初は0。

途中で一気に狂ってきて、最終的には全部違う値になると。

時間軸で見てみると、やっぱり8msecくらいで起きている。なぜこうなるかは、偉い人が言っているのをわたしはただ図にしただけです。

2つのスレッドが来た時に、一個一個しか動かしません。その切り方で一番単純なタイムスライス方式をとっていると。

5msec足すと、あんたやめなさいよと。そして次の人が動き出す。そして戻った時に前の状態とうまくつながらないというのがあるわけです。

ですから、速くGILをやめなさいと。値を正しくしたかったら5msecで終わるか、GIL-Safeのオペレーションで全部書きなさいということになります。

いかにしてGIL-Safeにするか

どうやってGIL-Safeにするか。これはCPythonの場合なんですが、Cに入った時にマクロが用意されていて、Thread2でCPythonのモードになったら、もうGILから離れますよというマクロを出すわけです。

すると、GILは別のスレッドをアクティブにすると。Cは今度はOSのスレッドとしてパラレルに動くわけです。そして終わったらまた入れてちょうだいよと。

ここで1つのポイントは、GILを離れると、Pythonのオブジェクトをアクセスしてはいけないわけです。そうすると自分の環境にCopy_inしてきて、終わったらモディファイしているものは戻してやるというオーバーヘッドが、どんな入れ方にしても必要になってくるわけです。

π計算とはどういうことかというと、正方形にランダムに玉を打ち込んで、その円弧に入った確率を4倍にすると、それはπですよと。これも乱数を2つとって、二乗を求めて、その中で1よりも小さいものをカウントすればいいわけです。

これはCで簡単に1対1に変換できます。Cに入れて、先ほどのPThreadでマルチにかける。

マルチはこの場合nを1回でやるのではなくて、nをn/mにして、その回数だけ計算した結果をまとめればいいわけです。単純に並列処理ができるわけです。

そうやってやると、スレッドの数をキーにしてプロットすると、ほぼ線形に上がってきます。オーバーヘッドがあるために、4個の時に少し下がりますが、上げてもダメ。

結局この処理に関しては、マルチスレッドが有効ではない。だけどReal Coreに関しては、かなり有効だねということがわかります。

NumPyのスピードアップ

NumPyなんですが、先ほどのfor文で回したものをVector文にすればいいわけです。そうすると、それだけで8倍ぐらい速くなります。というのは、Vector演算というのは、Cでもう書かれてコンパイルされているからなんです。

先ほどの二十何倍速くなりますので、そこまではいっていないんですが、Summaryですが、要するにスピードを上げたかったらGILを使ってはダメですよと。

安全を保つんだったらGIL-Safeを使いなさいというのがこのメッセージになります。

最初のパッケージなんですが、Numbaです。これは非常に簡単に高速にします。ここに書いてある文を1つ関数定義の上に置くだけです。

あとはCondaが開発してオープンにしているプロダクトになります。2つのシステムからなっていると考えればよろしいかと思います。CPU対象とCUDA対象。

CPUの場合には、基本的にはPythonで書かれているものをそのまま高速にするという発想です。

ただ、コンパイルですから、すべては対応できない。何が対応できる、できないことは明確にマニュアルに書いてありますし、流せばエラーが出ますから、すぐわかります。NumPyもある程度サポートしている。

かたやCUDAは、CUDAのKernelを意識したPythonを書くことになります。ですから、ここで書いたものはほかのPythonでは動きません。あとはNumPyもそのままでは動きません。ということで、これはあまり詳細はお話しません。

先ほどの一番簡単なことをやると、これで21倍ぐらいになります。

先ほど、マニュアルでやったものというのは、だいたい23倍ぐらいになるんです。ですから、手でやるよりは少し遅いけど、工数とか考えるとこっちのほうがずっと楽ですねということは言えます。

NumPyの先ほどのものをかけても、高速にはなりません。しかし、indexingを速くしてくれます。

実は、indexingと言っているのは、for文で回してindexingして、算出演算をするとif文をかけるということになるわけですが、これはNumPyがListよりも遅いわけです。4倍遅いんです。僕はこれはやって初めて気が付いたんですけど。

この真ん中にあるfor文を、Jitをかけると800倍ぐらい速くなります。

これは単純にデモ用のプログラムだからこれぐらいでしょうと。100倍になりますよというのは、実際の数値計算でベンチマークした人がいます。僕はそれを確認するためにこれをつくったような感じなんですけれども。

すべてVector演算できないわけです。普通の世界にあるものをVectorに打ち込んで、Vectorをつくらなきゃいけない。計算して出てきた結果は普通の環境に戻すために、サーチしてなんらかの処理が必要になってくる。そこでガンと堕ちるんです。

実際落ちるというのは、今日の一番最後のスライドでお見せいたします。