Metal Best Practicesは、iOS/MacOS/tvOSのAPIであるMetalを用いた設計のベストプラクティスガイドです。
本稿では、何回かに分けてこのガイドを読み解き、コード上での実験を交えて解説していきます。
読んでそのまま理解できそうなところは飛ばしますので、原文を読みながら原文のガイドとしてご利用下さい。
また、iOSの記事なので他のOS(MacOS, tvOS)についての記載は割愛します。
他の記事の一覧は、初回記事よりご覧下さい。
Functions and Libraries (関数とライブラリ)
ベストプラクティス:関数をコンパイルし、ビルド時にあわせてライブラリをビルドします。
関数は実行時にコンパイルすると大きなコストがかかるので、ビルド時にあわせてコンパイルもしましょう、という話です。
またライブラリはなるべく単一のデフォルトライブラリにまとめましょう、ということも書かれています。
これについては永続オブジェクトでも同じことが書かれていましたね。
コードで検証してみる
実行時にビルドしたら時間がかかるのは当たり前ですが、どのぐらいのコストがかかるのか計測してみたいと思います。
サンプルコードはこちらを使用します。
実行するとこのようなシンプルな図形が表示されます。
1. 事前ビルドの場合
事前にライブラリをビルドした場合を計測してみます。
os_signpost
を使用して計測します。不可をかけるために1万回ループしてライブラリを作成します。
let log = OSLog(subsystem: "com.example.myapp", category: "Performance")
os_signpost(.begin, log: log, name: "make library")
for _ in 0..<10000 {
guard let library = self.metalDevice.makeDefaultLibrary() else {fatalError()}
}
os_signpost(.end, log: log, name: "make library")
実行してみると、このあいだの処理時間は1.45秒でした。
2. 実行時ビルドの場合
実行時ビルドの場合は、シェーダーを文字列として与えます。
ちょっと長いですが、次のようにシェーダーを文字列化します。
let shader = """
#include <metal_stdlib>
using namespace metal;
struct Uniforms {
float time;
float aspectRatio;
vector_float2 touch;
vector_float4 resolution;
};
struct ColorInOut
{
float4 position [[ position ]];
float size [[point_size]];
float2 texCords;
};
vertex ColorInOut simpleVertexShader(
const device float4 *positions [[ buffer(0)]],
const device float2 *texCords [[ buffer(1)]],
constant Uniforms &uniforms [[buffer(2)]],
uint vid [[ vertex_id ]]
) {
ColorInOut out;
out.position = positions[vid];
out.size = 5.0f;
out.texCords = texCords[vid];
return out;
}
bool inCircle(float2 position, float2 offset, float size) {
float len = length(position - offset);
if (len < size) {
return true;
}
return false;
}
bool inRect(float2 position, float2 offset, float size) {
float2 q = (position - offset) / size;
if (abs(q.x) < 1.0 && abs(q.y) < 1.0) {
return true;
}
return false;
}
bool inEllipse(float2 position, float2 offset, float2 prop, float size) {
float2 q = (position - offset) / prop;
if (length(q) < size) {
return true;
}
return false;
}
fragment float4 simpleShapeFragmentShader(
ColorInOut in [[ stage_in ]],
constant Uniforms &uniforms [[buffer(1)]]) {
float3 destColor = float3(1.0, 1.0, 1.0);
float2 position = (in.position.xy * 2.0 - uniforms.resolution.xy) / min(uniforms.resolution.x, uniforms.resolution.y);
if (inCircle (position, float2( 0.1, -0.1), 0.5)) {
destColor *= float3(1.0, 0.0, 0.0);
}
if (inRect(position, float2(0.5, -0.5), 0.25)) {
destColor *= float3(0.0, 0.0, 1.0);
}
if (inEllipse(position, float2(-0.5, -0.3), float2(1.0, 1.0), 0.3)) {
destColor *= float3(0.0, 1.0, 0.0);
}
return float4(destColor, 1);
}
"""
そして、ランタイムビルドを1万回実行します。
os_signpost(.begin, log: log, name: "make library")
for _ in 0..<10000 {
do {
let library = try self.metalDevice.makeLibrary(source: shader, options: nil)
} catch {}
}
os_signpost(.end, log: log, name: "make library")
実行してみると、なんと処理時間は269.49msでした。ランタイムビルドしたほうが大幅に早かったです。
結論
ということで、今回もベストプラクティスのとおりの結果にはなりませんでした。ただ、今回は計測の方法に問題があっただけであり、ベストプラクティスが否定されたわけではなさそうです。
というのも、事前ビルドしている場合は、makeDefaultLibrary
を1度だけ呼べば良いわけですが、実行時にダイナミックにシェーダーを生成してビルドする場合は、その都度に必ずmakeLibrary
を呼ぶ必要があるためです。そのような条件で比較した場合、事前ビルドのほうが良いパフォーマンスが出るはずです。
それと、本題とは違いますが実行時にシェーダーをビルドするというのはdo
~catch
で出てきたエラーを確認する必要があるので、デバッグがとても大変でした。
最後に
iOSを使った3D処理やAR、ML、音声処理などの作品やサンプル、技術情報を発信しています。
作品ができたらTwitterで発信していきますのでフォローをお願いします🙏
Twitterは作品や記事のリンクを貼っています。
https://twitter.com/jugemjugemjugem
Qiitaは、iOS開発、とくにARや機械学習、グラフィックス処理、音声処理について発信しています。
https://qiita.com/TokyoYoshida
Noteでは、連載記事を書いています。
https://note.com/tokyoyoshida
Zennは機械学習が多めです。
https://zenn.dev/tokyoyoshida