速さの秘密

Swiftの発表以来、スピードはマーケティングの重要な要素でした。 それでも-それは言語のまさに名前で言及されています( 迅速、英語-「速い」 )。 PythonやJavascriptなどの動的言語よりも高速であり、Objective-Cよりも潜在的に高速であり、場合によってはCよりも高速であると言われています。 しかし、彼らはどうやってそれをしたのでしょうか?



憶測



言語自体が最適化の大きな機会を提供するという事実にもかかわらず、コンパイラの現在のバージョンはそれで大丈夫ではなく、パフォーマンステストで少なくともある程度成功するために多くの力がかかりました。 これは主に、コンパイラーが多くの冗長なリリース保持アクションを生成するという事実によるものです。 これは将来のバージョンですぐに修正されると思いますが、今のところ、Swiftが将来的にObjective-Cよりも高速になる可能性がある理由について話さなければなりません。



メソッドの迅速なディスパッチ



ご存知のように、Objective-Cでメソッドを呼び出すたびに、コンパイラはそれをobjc_msgSend



関数の呼び出しにobjc_msgSend



ますobjc_msgSend



関数は、実行時に必要なメソッドを検索して呼び出します。 メソッドセレクターとメソッドテーブル内のオブジェクトを受け取り、この呼び出しを処理するコードの即時部分を検索します。 この関数は非常に高速に動作しますが、多くの場合、実際に必要な以上の処理を行います。



メッセージの受け渡しは、コンパイラーがランタイム中にどのオブジェクトに遭遇するかについて何も仮定しないという点で便利です。 式型のインスタンス、子クラス、または完全に異なるクラスでもかまいません。 コンパイラはだまされる可能性があり、すべてが期待どおりに動作します。



一方、99.999%のケースでは、コンパイラーに嘘をつかないでしょう。 オブジェクトがNSView *



として宣言されている場合、それは直接NSView



または子クラスのいずれかです。 動的なディスパッチは必要ですが、実際のメッセージ転送は実際には不要ですが、Objective-Cの性質により、常に最も「高価な」タイプの呼び出しを使用できます。



サンプルのSwiftコードを次に示します。



 class Class { func testmethod1() { print("testmethod1") } @final func testmethod2() { print("testmethod2") } } class Subclass: Class { override func testmethod1() { print("overridden testmethod1") } } func TestFunc(obj: Class) { obj.testmethod1() obj.testmethod2() }
      
      





同等のObjective-Cコードでは、コンパイラは両方のメソッド呼び出しobj_msgSend



呼び出しobj_msgSend



ます-これで終わりです。



Swiftでは、コンパイラーは言語によって提供されるより厳格な保証を利用できます。 コンパイラーに嘘をつくことはできません。 式のタイプがClass



場合、オブジェクトはこのタイプの直接のオブジェクトまたは子のいずれかです。



Swiftコンパイラーは、 objc_msgSend



を呼び出す代わりに、仮想呼び出しテーブルを使用してメソッドを呼び出すコードを生成します。 基本的に、それはクラス内に格納された単なる関数ポインタの配列です。 コンパイラが最初の呼び出しに対して生成するコードは次のようになります。



 methodImplementation = object->class.vtable[indexOfMethod1] methodImplementation()
      
      





objc_msgSend



すべてのキャッシングとアセンブラの最適化にもかかわらず、配列インデックスへの通常のアクセスは常にはるかに高速になり、これはobjc_msgSend



なプラスです。



testMethod2



testMethod2



と、さらに改善されます。 @final



修飾子で宣言されているため、コンパイラはこのメソッドがどこでもオーバーライドされないことを保証できます。 次に何が起こっても、メソッド呼び出しは常にClass



クラスの実装に関連付けられます。 これにより、仮想メソッドテーブルへの参照を使用することさえできず、実装を直接呼び出すことができます。この場合、内部名__TFC9speedtest5Class11testmethod2fS0_FT_T_



ます。



もちろん、これはパフォーマンスの面でそれほど大きなブレークスルーではありません。 さらに、Swiftは引き続きobjc_msgSend



を使用してObjective-Cオブジェクトにアクセスしますが、いずれにしてもパーセントを提供します。



よりスマートなメソッド呼び出し



メソッド呼び出しの最適化は、より最適なスケジューリングスキームを使用するよりもはるかに重要です。 コンパイラーは、制御フローを分析してそれらを生成できます。 たとえば、メソッド呼び出しはインライン化または削除することができます。



たとえば、 testmethod2



メソッドの本体を取得して削除し、空のままにします。



 @final func testmethod2() {}
      
      





コンパイラーは、このメソッドは何もしないと推測できました。 最適化を有効にすると、このメソッドの呼び出しはまったく生成されません。 testmethod1



testmethod1



-それだけです。



同様のアプローチは、 @final



属性でマークされたメソッドだけで機能しません。 たとえば、コードが次のようにわずかに変更された場合:



 let obj = Class() obj.testmethod1() obj.testmethod2()
      
      





コンパイラーは変数obj



初期化の場所と内容を確認するため、 testmethod1



呼び出されるまでに、子クラスのオブジェクトがそれに入ることはできません。したがって、最初または2番目のケースでは動的ディスパッチは不要です。



別の極端なケースを考えてみましょう



 for i in 0..1000000 { obj.testmethod2() }
      
      





Objective-Cでは、このコードは100万件のメッセージを送信し、かなりの時間がかかります。 ただし、Swiftはメソッドに副作用がないことを認識しており、明確な良心をもってループ全体をクリーンアップできるため、コードを即座に実行できます。



少ないメモリ割り当て操作



十分な情報があれば、コンパイラは不要なメモリ割り当て操作を削除できます。 たとえば、オブジェクトの作成と使用のすべてのケースがローカルスコープに制限されている場合、ヒープの代わりにスタックに配置できます。これははるかに高速です。 まれに、オブジェクトのメソッド呼び出しがオブジェクト自体を使用しない場合、その配置を完全に省略することができます! ここに、例えば、かなり面白いObjective-Cコードがあります:



 for(int i = 0; i < 1000000; i++) [[[NSObject alloc] init] self];
      
      





Objective-Cは、300万のメッセージを送信することにより、100万のオブジェクトを正直に作成および削除します。 同等のSwiftコードは、十分なコンパイラーがある場合、 self



メソッドが何も役に立たず、呼び出されたオブジェクトを参照しない場合、このコードの命令をまったく生成できません。



レジスタのより効率的な使用



Objective-Cの各メソッドは、2つの暗黙的なパラメーター_cmd



_cmd



、その後、他のすべてが渡されます。 ほとんどのアーキテクチャ(x86-64、ARM、ARM64を含む)では、最初のパラメーターはレジスターを介して渡され、残りはスタックにプッシュされます。 レジスタは非常に高速に動作するため、レジスタを介してパラメータを渡すとパフォーマンスに影響する可能性があります。



暗黙のパラメーター_cmd



ほとんど使用されません。 Objective-Cコードの99.999%が決して実行しない実際の動的メッセージ転送を使用する場合にのみ必要です。 この場合、レジスタはまだ使用されていますが、それほど多くはありません。ARM-4、x86-64-6、ARM64-8。



Swiftにはそのようなパラメーターはありません。これにより、レジスタを介してより「有用な」パラメーターを転送できます。 多くの引数を取るメソッドの場合、これは呼び出しごとのパフォーマンスのわずかな向上も意味します。



重複ポインター



SwiftがObjective-Cより高速である多くの例がありますが、通常のCはどうですか?



ポインタは、それに加えて同じメモリ領域への別のポインタがある場合、重複していると見なされます。 例:



 int *ptrA = malloc(100 * sizeof(*ptrA)); int *ptrB = ptrA;
      
      





状況は単純ではありませんptrA



への書き込みは、 ptrA



からのptrA



に影響し、その逆も同様です。 これは、コンパイラが実行できる最適化に悪影響を与える可能性があります。



たとえば、標準ライブラリのmemcpy



関数の単純な実装を次に示します。



 void *mymemcpy(void *dst, const void *src, size_t n) { char *dstBytes = dst; const char *srcBytes = src; for(size_t i = 0; i < n; i++) dstBytes[i] = srcBytes[i]; return dst; }
      
      





もちろん、バイト単位でデータをコピーすることは完全に非効率的です。 多くの場合、データをより大きなチャンクでコピーしたいと考えています。SIMD命令を使用すると、16バイトまたは32バイトを一度に転送できるため、機能が数倍高速になります。 理論的には、コンパイラは与えられたループの目的を推測し、これらの命令を使用する必要がありますが、ポインターを複製する可能性があるため、これを行う権利はありません。



理解するために、次のコードを見てみましょう。



 char *src = strdup("hello, world"); char *dst = src + 1; mymemcpy(dst, src, strlen(dst));
      
      





標準のmemcpy



関数を使用すると、重複するデータ領域をコピーできないため、エラーが発生します。 この関数にはこのようなチェックが含まれておらず、この場合は予期しない方法で動作します。最初の反復では、文字 ' h



'は位置1から位置2にコピーされ、2番目では文字2から3にコピーされます。同じシンボルで詰まることはありません。 私たちが待っていたものではありません。

このため、 memcpy



はオーバーラップするポインターを受け入れません。 このような場合には、特別なmemmove



機能がありますが、追加の操作が必要であり、それに応じて動作が遅くなります。



コンパイラーはこのコンテキストについて何も知りません。 彼は、関数に重複しないポインターを渡すつもりであることを知りません。 ポインターが重なる場合と重ならない場合の2つの場合を考慮すると、一方の結果が変更された場合、一方の最適化を実行できません。 現時点では、コンパイラは文字列「 hhhhhhhhhhhh



」を取得することのみを理解しています。 必要です。 私たちが書いたコードにはこれが必要です。 このケースでは、絶対に気にせずに最適化を行っても、この場合の動作はそのままにしておく必要があります。



Clangはこの機能の素晴らしい仕事をしました。 ポインターのオーバーラップをチェックするコードを生成し、最適化されたアルゴリズムのみを使用します。 このチェックの計算の複雑さは、コンテキストの知識が不足しているためにコンパイラに強制されますが、非常に小さいですが、ゼロではありません。



この問題は、同じタイプの2つのポインターが同じメモリ領域を参照できるため、Cでは非常に一般的です。 ほとんどのコードは、ポインターが交差しないことを前提に書かれていますが、デフォルトのコンパイラーはこの可能性を考慮すべきです。 このため、プログラムを最適化することは難しく、実行速度は遅くなります。



この問題のvalence延により、新しいrestrict



キーワードがC99標準に追加されました。 ポインターが交差しないことをコンパイラーに伝えます。 この修飾子をパラメーターに適用すると、生成されるコードがより最適になります。



  void *mymemcpy(void * restrict dst, const void * restrict src, size_t n) { char *dstBytes = dst; const char *srcBytes = src; for(size_t i = 0; i < n; i++) dstBytes[i] = srcBytes[i]; return dst; }
      
      





問題が解決されたと想定できますか? しかし... ...コードでこのキーワードを使用した頻度はどれくらいですか? ほとんどの読者の答えは「一度だけ」だと思う。 私の場合、上記の例を書いている間に人生で初めて使用しました。 生産性にとって最も重要な場所に使用されますが、それ以外の場合は、単に非最適性を吐き出し、先に進みます。



ポインターが重なると、予期しない場所に表示される場合があります。 例:



 - (int)zero { _count++; memset(_ptr, 0, _size); return _count; }
      
      





コンパイラは、 _count



と同じ場所を指していると仮定するように強制され_ptr



。 したがって、 _count



をインクリメントし、その値を保存し、 memset



呼び出してから_count



再度読み取って返すコードを生成します。 _count



memset



中に変更できないことを知っています。また、再度読み取る必要はありませんが、コンパイラはこれを行う必要があります。 この例を次と比較してください。



 - (int)zero { memset(_ptr, 0, _size); _count++; return _count; }
      
      





memset



呼び出しがプッシュされると、 _count



を再読み取りする_count



なくなります。 これは小さな勝利ですが、それでもそうです。



一見無害なNSError **



さえ違いを生むことができます。 インターフェースがエラーの可能性を想定しているメソッドを想像してください。しかし、現在の実装は決してそれを呼び出しません:



  - (int)increment: (NSError **)outError { _count++; *outError = nil; return _count; }
      
      





繰り返しになりますが、 _count



count



と同じoutError



outError



た場合、コンパイラは_count



冗長な読み取りを生成するように強制されcount



。 これは非常に奇妙なことです。Cの規則では通常、異なるタイプのポインターを重複させることができないため、この読み取りをスローすることは非常に安全だからです。 どうやら、Objective-Cはアドオンでこれらのルールを何らかの形で破ります。 もちろん、 restrict



を追加できますが、適切なタイミングでこれを覚えることはほとんどできません。



Swiftコードでは、これはあまり一般的ではありません。原則として、任意のオブジェクトへのポインターを操作する必要はありません。また、配列のセマンティクスでは、ポインターをオーバーラップさせることはできません。 これにより、Swiftコンパイラーは、オーバーラップポインターを使用する場合に、余分な保存と読み取りを少なくして、より最適なコードを生成できます。



まとめると



Swiftには、Objective Cよりも最適なコードを生成できるいくつかのトリックがあります。動的ディスパッチが少なく、メソッドを埋め込み、不要な引数の受け渡しを拒否することにより、呼び出し速度が向上します。 また、Swiftではポインターが非常にまれなので、コンパイラーはより効率的な最適化を実行できます。



翻訳者注:



この記事は1か月半前に書かれました。 それ以来、実際にオプティマイザーの優れた機能を確認する投稿がすでに登場ています。 英語の知識は必要ありません。表をご覧ください。



All Articles