低価格スマホでコンピュートシェーダ

この記事はTech KAYAC Advent Calendar 2019の2日目の記事です。

こんにちは。技術部平山です。

この記事では、「低価格帯の機械で状況を良くするためにコンピュートシェーダが使えないか?」 という考えを、簡単なサンプルで検証してみました。

結論

最初に結論を申し上げます。微妙。

まず数字を出しましょう。数字はあるテストでのフレームレート(fps, frames per second)です。

データサイズ CPU ComputeShader
1024 52 55
2048 15 17
4096 4 5

CPUからComputeShaderにすると、非常に負荷が大きい局面では25%くらい速くなるかな?というくらいです。 負荷が低い局面では差が小さくなってしまいます。 測定に用いた機械は、SharpのAndroid One S3、 チップはmsm8937(Snapdragon430)で、 2018年後半以降に登場した、低価格帯(3万円台以下)向けのチップです。

ついでに、たまたまそこにあった高級機種を見てみましょう。Galaxy S9+(SCV39)です。

データサイズ CPU ComputeShader
4096 16.5 60

高い機械は速いなあ、と思いますね。つくづく。 数字はfpsなので60で天井ですから、本当は100出るかもしれませんし、200出るかもしれません。 最低でも、CPUからGPUに持っていけば4倍近くの性能になる、ということがわかります。

さらに、MacBook Pro 13'' 2018でも同じ測定をしてみました。

データサイズ CPU ComputeShader
4096 18 60

これもGPU側は60より大きいかもしれませんが、 最低でも差が3倍近くある、ということです。

それにしても、PCとGalaxy S9+でCPU性能が大差ない、 というのはちょっと疑わしいですね。何か問題があるかもしれませんので、 数字は真に受けないようにおねがいいたします。 今回の実装についての懸念点、改良すべき点については後にまとめます。

測定方法

GitHubにプロジェクトを置いておきました

まず、ある大きさのRGBA32テクスチャにランダムな色を入れておき、 これを32bitの整数とみなして乱数アルゴリズムであるXorShiftで変換します。こんな奴です。

static void XorShift32(ref Color32 c)
{
    uint x = ((uint)c.a << 24) | ((uint)c.r << 16) | ((uint)c.g << 8) | c.r;
    x ^= x << 13;
    x ^= x >> 17;
    x ^= x << 5;
    c.a = (byte)(x >> 24);
    c.r = (byte)((x >> 16) & 0xff);
    c.g = (byte)((x >> 8) & 0xff);
    c.b = (byte)(x & 0xff);
}

RGBA結合して32bit整数をつくり、謎の計算(13,17,5が出てくるところ)をして、 またRGBAに戻しています。32bit整数1チャネルのテクスチャフォーマットを使えば こんな必要はないのですが、どの機械でも動くのかがわからないので、確実に動くRGBA32にしておきました。

1024x1024のテクスチャであれば、100万個ほどの乱数の系列ができ、 これを独立に更新していく、という計算になります。 各要素間は完全に独立なので、GPU向きの計算と言えます。

ちなみに、画面はこんなです。

f:id:hirasho0:20191128160538p:plain

この例だと、解像度は64x64なのでツブツブ模様がよく見えますね。 「GPU」のチェックボックスはoffなのでCPU計算しており、 フレームレート(FPS)は60.4、「Th: 3/4」とあるのは、 CPU計算時のスレッド数が3、Unityが返したプロセッサ数が4、という意味です。 スレッドをいくつ用意した時が一番速いかは、 案外やってみないとわかりません。なのでスライダーを用意しておきました。

ComputeShaderの場合

今回のComputeShaderは、先程のXorShiftの処理をGPUに移植しただけです。 同じ計算をGPUを使って行います。

今回はAndroidしか試しておらず、APIはVulkanのみです。 iOSは試していませんが、たぶんiPhone5S以降であれば動くのでしょう。 実装は後で紹介します。

測定値の詳細

もう一度データを載せます。

データサイズ CPU ComputeShader
1024 52 55
2048 15 17
4096 4 5

毎フレーム、2048x2048の各ピクセルについて、XorShiftで更新をかけると、 CPUではフレームレートが15fps、ComputeShader使用時には17fpsだった、ということです。 4096x4096にすれば計算量は4倍になりますので、性能が1/4に落ちることが想像されますが、そこまでは落ちません。 これは測定方法に問題があることと、フレームごとに発生するそれ以外の処理によるオーバーヘッドがあるからです。

測定方法の問題というのは、VSYNC、つまり垂直同期のことで、 秒間60fps以上は出ず、秒間fpsを保てなくなるとガクンと30fpsまで落ちてしまう、 という特性によります。きちんと測るなら、かつてのKonchiBench で行ったような手続きが必要でしょうが、今回は粗くわかれば良いとして、真面目な測定を作りませんでした。

CPU側の解釈

さて、このデータはsnapdragon430のデータでして、CPUは8コアです。 適切なスレッド数で測らないとCPUの性能を引き出せずGPUが有利になってしまいますから、 調べておきましょう。解像度2048での測定です。

スレッド数 fps
1 8
2 13
3 15
4 15
5 14
6 14

だいたい2スレッドで1スレッドの1.5倍くらいの性能が出て、 もう1スレッド追加すると微妙に速くなり、5を超えると逆に遅くなる、 という結果です。おそらく同時に動いているのは4コアなのでしょう。 忙しい時は速い方の4コアだけが動き、ヒマな時は遅くて電気を食わない4コアが動く、 という設計なのだと思われます。

なお、適正なコア数は計算の内容に左右され、 例えば1024x1024だともう少しスレッドが多い方が速かったりします。 熱や電池のかねあいもあるのでしょう。

ともかく、この測定結果を見る限り、「Nコア使うと性能がN倍」というのは所詮は理想でしかない、 ということが言えるかと思います。 今回はスレッドプールが自作なので、そこの出来が悪いのかもしれませんが、 少なくとも私は「スレッド数を倍にして性能が倍になった」というケースは見たことがありません。 1.5倍でも良い方です。

流行りのJobSystem なら高速なのかもしれませんが、試してみたところ、エディタとStandaloneビルドで 自作スレッドプールに比べて1/4くらいになってしまいました。 今回はGPUが主役ですので、これ以上の検証はしていません。

GPUとCPUの比について考える

今回の結果からは、 「安い機械であっても、GPUでできることはGPUでやる方が速くはなるようだ」 ということが言えます。ただし、その度合いはかなり残念なレベルです。 ComputeShaderの設定を変えたら倍速になる、なんてことはたぶんないでしょう。

さて、ここで根本的なことについて考えておきます。 「CPUでもできることをGPUにやらせることは正義か?」という事です。

描画処理、つまりシェーダの実行はGPUにしかできません。 描画で忙しいのであれば、多少遅くてもCPUでやってGPUを描画に専念させる方が、 全体としては品質が上がるでしょう。 PLAYSTATION3のような「CPU側にすごい性能の謎コアがたくさんあって、 こいつも描画に参加させた方が速い」という変態マシンもありましたが、たぶん例外です。 大抵のゲームでは描画が一番重要な計算になるので、 GPUにはできるだけ描画をさせるべきでしょう。

ただし、ゲームの種類によっては、CPUが忙しくてGPUが余っていることもありえます。 例えば群体シミュレーションが凄まじく重いゲーム、とかですね。 こういう場合にはCPUから仕事を奪って、 積極的にGPUにやらせる方が良い可能性があります。

そして、この天秤は、GPUとCPUの性能比によって変わってくるわけです。

仮にGPUがCPUの100倍速ければ、「よほどのことがない限りCPUではやらない」 というのが正義になります。GPUから少々描画処理をする時間を奪ったとしても、 それによって空くCPU時間が大きいので、追加でいろいろやれるようになります。 全体としては品質が上がるでしょう。

しかし、この比率が下がってくるにつれ、「それくらいならCPUでやればいいかな」 という判断になる率が上がってくるわけです。 古くはスキニング処理が よく話題になりました。

また、新しいGPUには「描画しながら片手間にComputeShaderを実行できる」 という器用な奴もいます(AsyncComputeという奴です)。 こういう機械は「テクスチャアクセスで忙しいけど計算器空いてるから計算しとくわ」 みたいなことができ、性能を有効活用できます。 そういうGPUが一般的になれば、よりComputeShaderを使いたくなってくるわけですね。 ただし、2019年11月現在、 これがUnityから使えるのはPlaystation4だけみたいです

ComputeShaderの実装

さて、実はほぼ初めてComputeShaderを書いたような人間ですので、 偉そうに説明はできませんが、紹介だけはしておきます。

#pragma kernel CSMain

Texture2D<float4> Source;
RWTexture2D<float4> Destination;

[numthreads(8,8,1)]
void CSMain (uint3 threadId : SV_DispatchThreadID)
{
    float4 c = Source[threadId.xy] * 255.0;
    c += 0.5;
    int4 i = (int4)c;
    uint x = (i.w << 24) | (i.x << 16) | (i.y << 8)| i.z;
    x ^= x << 13;
    x ^= x >> 17;
    x ^= x << 5;
    i.x = (x >> 16) & 0xff;
    i.y = (x >> 8) & 0xff;
    i.z = x & 0xff;
    i.w = (x >> 24) & 0xff;
    c = i / 255.0;
    Destination[threadId.xy] = c;
}

シェーダ側はこのようになっています。入力がTexture2DであるSource、 出力がRWTexture2DであるDestinationで、Sourceから読んだ値を加工して Destinationに流すだけのシェーダです。

計算本体

計算の本体はCPU側とほぼ同じですが、 「テクスチャから抜いた値の範囲が0から255でなく、0から1.0」 というのが違います。255を乗じて整数化する必要がありますが、 この時に注意が必要です。

0から255、を、0から1に変換する際には255で割るわけですが、 IEEE754な浮動小数点数は255で割ると誤差が出ます。 例えば255/255が0.99999になれば、ここに255を掛けても254.9999 みたいな感じになり、整数に切り捨てると254になってしまいます。 ですので、ここでは0.5を足してから切り捨てています。

整数のまま出てくるように指定できればこんな面倒はないのですが、 Unityのやり方がわからなかったのでこのようにしました。

インデクスの計算

次に問題になるのがインデクスです。Source[index]とかDestination[index] のように添字でアクセスする時に、何を書けばいいか、ということですね。

結論から言えば簡単で、SV_DispatchThreadIDのセマンティクスでもらった 引数のxyをそのまま渡せば終わりです。 SourceもDestinationも2Dテクスチャですので、添字の型は2次元整数型です。 なので、threadId.xyのように2次元ベクトルを書けます。

あとはSV_DispatchThreadIDって何なの?という話ですが、 結論から言えば、処理しているテクセルの座標になっています。 ちゃんと説明すると面倒くさいのですが、 「全部でテクセルの数と同じだけ、例えば1024x1024回呼ばれるように設定されていれば、 (0,0)から(1023,1023)までの全ての組み合わせが一回づつ出てきます。 だから、Source[index]で値を取って、Destination[index]に入れれば、 全テクセルを処理できるわけです。

ただし、そうなるためにはCPU側も合わせて見ないといけません。次で軽く説明します。

C#側初期化

まず初期化です。

renderTextures = new RenderTexture[2];
for (int i = 0; i < renderTextures.Length; i++)
{
    renderTextures[i] = new RenderTexture(size, size, 1, RenderTextureFormat.ARGB32);
    renderTextures[i].enableRandomWrite = true;
    renderTextures[i].Create();
}
Graphics.Blit(texture2d, renderTextures[1]);

まずシェーダの入出力になるRenderTextureを作ります。 入力と出力を同じにできるかどうかは機種によるので、2枚用意して、 フレームごとに役割を入れ換えます。 最初は0番を入力とし、加工して1番に出力し、 次のフレームでは、1番を入力として、0番に出力するわけです。

コンストラクトしたら、enableRnadomWrite をtrueにしてComputeShaderで書けるようにしてから、 明示的にCreate()します。 Graphics.Blit() したり、RenderTexture.active にセットしたりすれば、中で勝手にCreateが呼ばれるのですが、ComputeShaderの場合は そうできない事情があるようですね。とにかく必要なので呼びます。

なお、最後の行のBlitは、CPUで生成したノイズテクスチャであるtexture2dから、最初の入力になる 1番RenderTextureにコピーするものです。 これ以降、一切CPUにはデータを返さず、2枚のRenderTextureの間でデータの往復が行われるだけになります。

C#側毎フレーム

あとは毎フレームの処理です。

var kernelIndex = computeShader.FindKernel("CSMain");
computeShader.SetTexture(kernelIndex, "Source", renderTextures[1 - writeBufferIndex]);
computeShader.SetTexture(kernelIndex, "Destination", renderTextures[writeBufferIndex]);
uint sizeX, sizeY, sizeZ;
computeShader.GetKernelThreadGroupSizes(
    kernelIndex,
    out sizeX,
    out sizeY,
    out sizeZ);
computeShader.Dispatch(
    kernelIndex,
    size / (int)sizeX,
    size / (int)sizeY,
    1);
image.texture = renderTextures[writeBufferIndex]; // これ同期待たないとダメでしょ感
writeBufferIndex = 1 - writeBufferIndex;

ComputeShader オブジェクトはSerializeField か何かで受け取ります。ここではcomputeShaderという変数です。

次に、CoumputeShader.FindKernel() で使う関数のIDを取ります。

var kernelIndex = computeShader.FindKernel("CSMain");

今回は関数が1個しかないので0固定ですが、 いくつかある場合もあるでしょうから、名前から番号に変換するのが良い作法でしょう。

そうしたら、ComputeShader.SetTexture() で入力と出力のRenderTextureをセットします。

computeShader.SetTexture(kernelIndex, "Source", renderTextures[1 - writeBufferIndex]);
computeShader.SetTexture(kernelIndex, "Destination", renderTextures[writeBufferIndex]);

writeBufferIndexという変数が、 「今どっちが出力か」を覚えていて、0か1が入っています。

次に、ComputeShader.GetKernelThreadGroupSizes() で、先程シェーダの中に書いた[numthreads(8,8,1)] みたいな記述を取ってきます。

uint sizeX, sizeY, sizeZ;
computeShader.GetKernelThreadGroupSizes(
    kernelIndex,
    out sizeX,
    out sizeY,
    out sizeZ);

ここではsizeXに8、sizeYに8、sizeZに1が帰ってきますね。 これが実行の単位で、テクスチャの8x8の64画素分を1単位として実行する、という意味になります。 ComputeShader用語では「1グループあたり8x8=64スレッド」ですね。

そして次にいよいよ実行です。

computeShader.Dispatch(
    kernelIndex,
    size / (int)sizeX,
    size / (int)sizeY,
    1);

さっきの「実行単位」をいくつ実行するかを指定します。 今sizeが1024なら、sizeXは8なので割ると128、 sizeYも8なので128になりますね。 つまり、8x8の実行単位を、128x128回実行しますよ、ということになり、 これで全テクセルをカヴァーできるわけです。 ComputeShader用語では「128x128=16384スレッドグループを実行する」ですね。

こういうふうにやっておけば、シェーダの中ではSV_DispatchThreadID のセマンティクスを持つ変数のxyを添字にしてテクスチャにアクセスすることで、 全テクセルを順番に見ていくことができます。

もっと凝ったことをやりたくなったら、その時はその時ですね。 例えば、縦横1/2に縮小するなら、実行回数は元のテクセル数の1/4です。 シェーダの中では4回Sourceにアクセスして、一回Destinationにアクセスするような コードになるでしょう。SV_DispatchThreadIDからどうやってテクスチャの添字を作るか、 に関してはいろいろ詳しい記事がありますので、探してみてください。 ここでは扱いません。この記事では「そもそも使える速度が出るのか?」 に絞って考え、「何に使うか」は考えないことにしています。

そして最後に、表示用のrawImageで使うTextureを差し換えて、 writeBufferIndexを0から1、あるいは1から0へと切り換えます。

writeBufferIndex = 1 - writeBufferIndex;

余談ですが、「1から引けば0と1が交代する」ということに、 私はこの世界に入って3年以上気づきませんでした。3年経って自分で思いついたわけではなく、 たまたま他人のコードを見て知っただけです。その時の悔しさはひどいものでした。 それまではインクリメントして2になったら0に戻していたのです。 どんなに簡単なことでも、「思いつく」ということは難しいことなのです。 何もかもを自力で作ることはできないのだ、ということを強く意識した出来事でした。

考察もう一度

さて、結果についてもう少し深く考えます。

「だいぶGPUに向いた処理を選んだにも関わらず、期待したほど速くない」 ということを、どう捉えるかです。

私の現状の結論は、「高性能機での品質を重視するか、今後の展望を考えればComputeShaderはアリ。 だが、無理しなくても良さそう」 という感じでしょうか。 4096解像度での低価格機(Sharp Android One S3)と高級機(Galaxy S9+)を比べてみましょう。

機種 CPU ComputeShader
S3 4 5
Galaxy S9+ 16.5 60

CPUは4倍程度、GPUは小さく見積っても12倍は違います。 GPUで差が激しいということは、Galaxy S9+ではComputeShaderを使うことがより有利になる、 ということです。

ただ、「低価格帯の機種であっても、CPUコアを総動員するよりはGPUにやらせた方が速い」 ということは言えますし、 慣れてしまえばCPUで書くよりGPUで書く方が楽、というケースもあります。 今回はスレッドプールを自作して、自力でスレッド数を決めて割り振りまでしていますから、 結構面倒なのです。その点GPUなら割り振りのことは考えずに済みますし、 計算の本体はアセットとして後からロードしたり配信したりできます。

コンピュートシェーダに回せそうな計算は一旦回してみる、 というのも良いかもしれません。その上で、 計算量を低価格機種と高価格機種で変えてあげれば良いのでしょう。 例えばパーティクルの計算に使うのであれば、その数を性能によって変えれば 良いように思います。

それに、いかに進歩が遅い低価格帯であっても、CPUが高速化する速度よりは、GPUが高速化する速度の方が まだマシですから、「今後GPUとCPUの性能差は開いていく。今からComputeShaderに慣れておくべき」 と言うことはできます。

とはいえ、無理して今やらなくてもいいな、という感じはあるわけですね。

Graphics APIについて

「ComputeShader必須」としてしまうと動く機械を狭めてしまう、という問題もあります。

Vulkanが動く機械が必要になり、OpenGLES3.0までの機械はダメです。 Androidの配信ダッシュボード を見ると、今動いている機械のOpenGL/Vulkan対応比率がわかるのですが、 3.0以下の機械がまだ半分くらいいます。 「ComputeShader必須」と言ってしまった瞬間に、世界にあるAndroidの半分が対象外になるわけです。

ちなみに、私が長らくお世話になった京セラS2も、vulkan非対応なので動きません。 今ComputeShaderを使うならば、非対応機種向けのケアは必要でしょう。

まとめ

案外遅かったですね。残念です。

このためにわざと「Computeが動く範囲でできるだけ性能が低そうな」 スマホを買ったのですが、期待した結果にはなりませんでした。

もちろん、実装が悪くて良いデータが出なかった、という可能性はあります。

  • フレームレートを評価値にしてしまったので、オーバーヘッドが大きい上に、60で天井になってしまう
  • 自作スレッドプールの実装が良くない可能性がある
  • ComputeShaderのスレッド/スレッドグループ設定に改良の余地があるかもしれない
  • 整数演算主体なので、GPUによっては不利があるのかもしれない
  • 計算が短いので、オーバーヘッドが多く出て本当の性能が見えにくいかもしれない。

などなど、多数の可能性が考えられます。

しかし、そんなことを吹き飛ばすほど速い、例えば10倍くらいの差が出ることを 期待していましたので、 そんな改造をして多少改善したとしても、どうでも良いことです。 1年は放っておいていいかな、という気分でおります。

おまけ

記事を書いた後に、測定に協力してくださった方が何人かいるので、そのデータも貼っておきます。 高級品が多いので、解像度は全て4096です。 比較用に、Sharp S3の結果も並べておきます。

機種 発売年 チップ CPU ComputeShader
Pixel3 2018 Snapdragon845 14.5 60.5
XPERIA XZ preium(SO-04J) 2017 Snapdragon835 10 56
Essential Phone(PH-1) 2017 Snapdragon835 10.5 47
XPERIA XZ(SO-01J) 2016 Snapdragon820 6-7 20
Nexus 6P 2015 Snapdragon810 0.9 9.5
Sharp S3 2018 Snapdragon430 4 5

測定は非常な誤差が伴うので、参考程度と考えてください。 例えばXPERIA XZで20fpsは低すぎる気がします。 また、Nexus6PのCPU計算が低いのも何か事情がありそうです。

それにしても、皆さん高級品ばかりですね。 安い機械と高い機械のGPU性能の絶望的な差がよくわかります。 やっぱり私くらいは低価格帯の機械使っておかないとダメだなという 思いを新たにいたしました。

...私も自分でスマホ向けのゲーム作ってなかったら 躊躇なく10万払って高級品買うんですよ? 貧乏なわけじゃないのです。