aokomoriuta's blog

青子守歌のブログ

C#はunsafeの方が速いという幻想

数日前に話題になったこの辺の話。

espresso3389.hatenablog.com
qiita.com

C++よりC#が速いかどうかというのはとりあえず置いておきましょう。
しかし、大元ネタが「unsafe使うと1.2倍速くなります!」と言ってますね。

よく聞きますよ、「C#で速度出したかったらunsafeにしなさい」って。
しかし本当にそうなのでしょうか?その謎を解明するため、我々探検隊はジャングルの奥地へ(ry

なおこの記事のタイトルは以前の『OpenCLやる前にSIMD使い切れっていう幻想』と合わせています。興味がある方はそちらもどうぞ。

結論

unsafeにしなくても速くできる!!

f:id:aokomoriuta:20160505130347j:plain

コードと環境は以下の通り。

連休中で出先でやってるのでマシンスペックが低いのはご容赦ください(誰かまともなやつでやってみた結果ください・・・)

ご覧のとおり、unsafeを使ったアンマネージドなtest2より、マネージドの世界のまま書いたtestManagedBitmapの方が1.5倍ぐらい速いです。
もちろん、速いコードを書くにはそれなりの知識と技術が必要で、誰でもどんな風に書いてもマネージドで速くなるわけではありません(だから仕事になるんですが)。
「誰でもぱっと乗せたらぱっと速い」というのはとても大事です(だからGPGPUが流行りました)。でもunsafeは「ぱっと速くする」とお手軽さを出すにはリスクが大きすぎます。せっかく安全で手軽なマネージドの世界で閉じていた部分に無理やりアンマネージドを許可するというのは、とても危険ですし(だからunsafeって名前なんです)、気にすべきことが増えすぎます。
それなら、同じ高速化したいのなら、マネージドの世界で閉じたまま、どうやったら速くできるのかを追求するほうが安全だし健全だし貴重な技術になります。
今回みたいにマネージドで書いたほうが実は速くなるならなおさらです。

「とてつもなく速くなる」とか「どうしても互換性のために仕方なく」という特殊な事情がないなら、やはりunsafeは使う必要はないし使うべきではないですね。

解説

流れとしては

  1. 最初の元コード(test1)のボトルネックを見つける
  2. test1のボトルネックを解決した(testManagedOpt)
  3. でもそもそも別のアプローチのほうが速かった(testManagedBitmap)

です。

元コードの問題点

まず最初に。
byteへのキャストが重いみたいな話が出てますが、嘘です。
ちゃんと分解すれば分かりますが、以下の様に一番重いのは書き込みです。
f:id:aokomoriuta:20160505134521j:plain
※byteへのキャストは最適化によってxorと融合されているので一見実行されていないように見える、詳しくは後述
みなさんは1行に複数の処理を入れた状態で「ここが遅い!!」みたいなことを言わないようにしてくださいね。

さて、ということで、a[dst]=vが遅いのは分かりました。
では、test1とtest2でなぜこれほど差がでるのでしょう?
ぱっと見れば「ポインタ演算が遅いのでは」と「境界チェックが邪魔なのでは」ぐらいは誰でも思いつくはずです。

それを確かめるためにどうしますか?まぁ当然、高速化を考えるなら、最初はとりあえずアセンブリ見るところから始めますよね。

  • test1f:id:aokomoriuta:20160505135237p:plain
  • test2f:id:aokomoriuta:20160505135516p:plain

ご覧のとおり、まず前者のポインタ演算については、x86に限らずほとんどのアーキテクチャでは代入(mov,store)時にオフセット演算も同時にできるようになっています。そして、現代のプロセッサならレイテンシスループットは変わらない事が多いです。
つまり*p=vもa[x]=vも多くの場合は大差ありません。今回もそうなのが分かります。

しかし、cmpとjmpがあるのが分かる通り、境界チェックは確かに消えてません。
ということで境界チェックを消しましょうか。

なお余談ですが、これは64bit版にすると、↑に加えて無駄なキャスト命令が入ります。理由は、(配列の)アドレスが64bitなのに対して、C#の配列はインデックスにint(つまり32bit)しかとれないからです。long(というかstd::size_t的なの)を受け付けるようにしてほしい・・・。

配列の境界チェックを消す

C#の配列は境界チェックがあります。そのおかげでオーバーランなどすることなく安全な世界にいます。
しかし、境界チェックは常に必要なわけではありません。明らかに安全な場合は、むしろ邪魔です。
つまり逆に言えば、明らかに安全なコードを書いてやれば、C#でも配列境界チェックはなくせてJITコンパイラからは高速なコードが生成されます。

というのはC#erの常識ですよね。その方法も古代から知られています
要は配列の長さでループを回してループカウンタでアクセスすれば良いんです。

for(int i = 0; i < a.Length; i++)
{
	a[i] = i; // これは境界チェックされない
}
for(int i = 0; i < n; i++)
{
	a[i] = i; // これは境界チェックされる
}

ということで、ループを1重にして配列の長さで回したのがtestManagedOptです。
f:id:aokomoriuta:20160505141423j:plain
アセンブリを見れば分かる通り境界チェックは消せて、アンマネージドなtest2と同じく、a[i]=(byte)(x^y);がxor,and,movになりました。

これで実行時間は7.0[s]から5.6[s]になり、アンマネージドの5.2[s]まで残り0.4[s]になりました。
しかしこの0.4[s]はどうやっても消せませんでした(やり方が思いつかなかっただけな気もするので出来た人いたら教えて下さい)。
理由は、配列の値をx^yとx,y座標を使っているところにあります。
そのため、どうしてもxとyの値を配列のインデックスとは別に生成する必要があり、その部分のオーバーヘッドが0.4[s]に乗っているようです。
f:id:aokomoriuta:20160505142331p:plain

普通の画像処理なら、大抵は配列へのアクセス時にしか座標値は出てこないので、座標値に明確に依存しなければならないことはまずありません。
なのでほとんどの場合はオーバーヘッドはなくせるはずなんですが、↑の理由から、今回の元の処理は偶然、境界チェックを消しづらいという特性を持っているというわけです。
それもそれでどうなんだというのもありますが、今回はあくまで相手の土俵で戦うしかありません。

ともかくそんなわけで、この方法ではunsafeにしないと(わずかですが)速く出来ないという状況になってしまいました。

System.Runtime.InteropServices.Marshal

で、速くならないなーって諦めて放置してたんですが、そういえば画像処理ということを思い出して、System.Drawing.Bitmapのことを思い出しました。
C#で画像処理っぽいことやってる人ならご存知の通り、BitmapのSetPixcelとGetPixelメソッドはとても重いんですよね。それを回避する方法としてLockBits/UnlockBitsで内部の(アンマネージドな)配列のハンドル(ポインタ)を取り出してSystem.Runtime.InteropServices.Marshalで操作するという方法があります。

そう。unsafeにしなくても、安全なマネージドな世界からより低レベルの操作をする方法がちゃんと用意されています。
それを思い出してSystem.Runtime.InteropServices.Marshalを使ったのがtestManagedBitmapです(最初はBitmapを使ってたんで名前にBitmapってついてますが、がそもそも配列でいいやってことを思い出して今はBitmapはなくなってただMarshalを使うだけです)。

これで

  • x,yループのままで(インデックスを増やすことなく)
  • 配列アクセスの境界チェックをなくせて
  • さらにメモリ書き込みを一気にやれるようになった

ので結果、かなり高速化されて、冒頭に書いた通り、マネージドのままでも3.3[s]になって、危険なことをしているアンマネージドな5.2[s]より1.5倍速くなりました。

めでたしめでたし。

※unsafe下でMarshal.Copyに該当するアンマネージドなメモリ書き込み方法は用意されていません

まとめ

ということで、危険なunsafeは高速化には必ずしも必要というわけではなく、安全なマネージドな世界にいたままでも十分に高速化できるのです。
unsafeを使うにせよなんにせよ、処理を高速化するというのは何かしら代償が伴います。unsafeを使うのもそうですし、ループを1重に書き換えて読みづらくなるというのもそう、Marshalを使うのだって(使ったことがない人には)リスクです。

そもそもMarshalはアンマネージドな操作をするためのものなので「マネージドとは言い切れないのでは」という批判もまぁごもっともです。
それでもunsafeみたいに完全にマネージドの外ではなく、ちゃんとマネージドの世界の中にいるというのはとても安全です。
自分の中でマネージドとアンマネージドの境界というのはゴム膜みたいなものだと思っていて、unsafeというのはそのゴム膜を突き破ってその穴からアンマネージドな世界を操作していて、Marshalは膜自体は突き破らずに棒でグリグリ押してあくまでマネージドの世界で操作している、という情景を思い浮かべます。

またMarshalを使わずとも、書き方を工夫すれば、アンマネージドに近い速度を出すことができることも示しました。

それでも、書き方を工夫したくない、Marshalも使いたくない、unsafeの方が良いという人もいてもいいとは思います。
自分がやりやすいようにやるというのも大事ですからね。
でもそれは趣味の話であって、そんな幻想をいつまでも固辞して信じ続けていると、きっとどこかで行き詰まりそうだと私は思います。

なんでもかんでも高速化のために危険を冒してまでunsafeにするのではなく、自分の目的に合わせて最適なものを選ぶという姿勢がやはり大事ですね。
私は「速いものは正義」の世界に生きているので、書きやすさ読みやすさを犠牲にしてでも速くなる方を選びます(限度はもちろんありますが)。