KOTET'S PERSONAL BLOG

#dlang 駆け足の人生:GCを減らすための工夫【翻訳】

Created: , Last modified:
#dlang #tech #translation #dlang_gc_series #d_blog

これは1年以上前の記事です

ここに書かれている情報、見解は現在のものとは異なっている場合があります。

この記事は、

Life in the Fast Lane – The D Blog1

を自分用に翻訳したものを 許可を得て 公開するものである。

今回だいぶ翻訳が怪しいので、誤字や誤訳などを見つけたら今すぐ Pull requestだ!


私が書いたGCシリーズ最初の投稿 (訳注:翻訳版) では、Dのガベージコレクタとそれを使う言語機能について紹介しました。 記事で取り上げた2つのキーポイントは:

  1. GCはメモリアロケーションが要求された時にのみ実行されます。 一般的な思い込みに反して、DのGCはふつうあなたのMinecraftクローンをホットパスの途中で止めようとしたりしません。 それはGCヒープからのメモリが要求され、そしてそれが必要なときのみ実行されます。
  2. 単純なCやC++のアロケーション戦略でGCの忙しさを和らげることができます。 内側のループでメモリをアロケートしないでください。 可能な限り多く事前アロケートをするか、かわりにスタックから取得してください。 ヒープアロケーションの合計数を最小化しましょう。 上記のポイント#1のためにこれらの戦略は機能します。 単にGCヒープアロケーションが行われるタイミングを賢くすることで、 プログラマはコレクションを行うことができるタイミングを指示することができます。

ポイント#2の戦略はプログラマが自身で書いたコードには有効ですが、サードパーティーライブラリには全く役に立ちません。 そのような場面のために、DはGCアロケーションが発生しないことを、 言語とランタイム両方で保証する組み込みの仕組みを提供しています。 GCが関わらないようにするのに役立つコマンドラインオプションもあります。

J.P.という名の仮のプログラマを想像してみてください、彼はそれが有効だと思っているので、 彼のDプログラムからガベージコレクションを完全に避けたいと思っています。 彼には即時取れる2つの選択肢があります。

GC鎮静剤

1つの選択肢はプログラムが開始する時にGC.disableを呼ぶことです。 これはアロケーションを止めませんが、コレクションを停止させます。 ここでいうコレクションとは、他のスレッドでのアロケーションによるものを含むすべてのコレクションを意味します。

void main() {
    import core.memory;
    import std.stdio;
    GC.disable;
    writeln("Goodbye, GC!");
}

出力:

Goodbye, GC!

これにはGCヒープを使うすべての言語機能が期待通りに動くという利点があります。 しかし、クリーンアップなしにアロケーションが続いていくことを考えると、 ちょっと計算すればこれが問題だということがわかるでしょう。 アロケーションが手に負えなくなった時、なにかを妥協しなくてはならなくなるでしょう。 ドキュメント曰く:

メモリ不足時など、ガベージコレクタによる回収動作がプログラムの正常動作に不可欠な場合は、 disable 状態でもガベージコレクタが動くことがあります。2

J.P.の視点にたつと、これは良いことではないでしょう。 しかしこの制約が容認できるなら、物事をコントロールするのに役立ついくつかのステップがあります。 J.P.は必要に応じてGC.enableまたはGC.collectを呼ぶことができます。 これは単純なCやC++のアロケーション戦略よりも優れたコレクションサイクルへのコントロールを提供します。

GCの壁

単にGCが耐え難い時は、J.P.は@nogc属性をつけることができます。 それをmain関数の前につけることでコレクションに苦しむことはなくなります。

@nogc
void main() { ... }

これは究極のGC軽減戦略です。 @nogcmainのコールスタックの先でガベージコレクタが動作しないことの保証を適用します。 「実装がどこでコレクティングを必要と判断するか」を気にする必要はありません。

一見して、これはGC.disableよりもよい選択肢に見えます。 やってみましょう。

@nogc
void main() {
    import std.stdio;
    writeln("GC be gone!");
}

このとき、コンパイラを通すことはできません。

Error: @nogc function 'D main' cannot call non-@nogc function 'std.stdio.writeln!string.writeln'

@nogc印はそれを強制するコンパイラの能力です。 非常に極端なアプローチです。 関数が@nogcとつけられたとき、その内部から呼びだされた関数にも@nogcがついていなければなりません。 当然、writelnはそうではありません。

それだけではありません:

@nogc 
void main() {
    auto ints = new int[](100);
}

コンパイラはこれも見逃しません。

Error: cannot use 'new' in @nogc function 'D main'

GCヒープからアロケートをする言語機能は@nogcとマークした関数には立ち入れません (これらの機能については、 このシリーズの最初のポストを参照してください)。 これはどこまでも遡って続きます。 ここでの大きな利点は、サードパーティのコードもそれらの機能を使うことができず、 背後でGCメモリのアロケーティングが行われないことが保証されることです。 不都合な点は、@nogcについて認識していないサードパーティライブラリがプログラム中で使用できないことです。

このアプローチを用いるには@nogcでない言語機能や、 標準ライブラリのいくつかを含むライブラリ関数を補うためのいくつかの回避策が必要です。 とるに足らないものもありますが、いくらかはそうではなく、ほかはほとんど全部動作しません (詳細は将来の投稿で掘り下げていきます)。 明らかでない例は例外を投げることです。 慣用的方法は:

throw new Exception("Blah");

この行のnewのために、@nogc関数内でこれは使えません。 これを回避するには投げる例外を事前アロケートする必要があります。 これは、通常のヒープから割り当てられた例外メモリの割り当てを解除する必要があり、 参照カウントやスタック割り当てのアイディアにつながるという問題があります…… 言い換えるならば、これは難しい問題です。 現在Walter Brightによる、それが必要なときはGCなしでthrow new Exceptionを動作させることを意図した D Improvement Proposal があります。

@nogc mainの制限を回避するのは決して乗り越えられない問題ではありませんが、かなりのやる気と献身が必要です。

もうひとつ@nogc mainはプログラムから完全にGCをなくすわけではないことにも触れておきましょう。 Dは静的コンストラクタとデストラクタ をサポートしています。 前者は実行時mainに入る前に、後者は抜ける時に実行されます。 これらのいずれかがプログラムに存在し、かつ@nogcとアノテートされていない場合、 技術的にはGCアロケーションとコレクションはプログラムに存在できます。 しかし依然として@nogcはいちどmainに入ったら一切のコレクションが起きないことを意味し、 事実上全くGCがないのと変わりません。

努力する

ここまでで私は選択肢を提案しました。 GCを完全に無効にしたり切ったりせずに書けるプログラムはたくさんあります。 GCアロケーションを最小化しホットパスから離すという単純な戦略は多くの利点があり、 望ましいものです。 それがどれほど誤解されているかを考えれば、何度でも繰り返さずにはいられません: DのGCはプログラマがGCメモリをアロケートした時にのみ実行の機会を得て、 そしてそれが必要とされる時のみ実行されます。 この知識を活用して、アロケーションを小さく、少なく、インナーループから離してください。

多くの制御が本当に必要なプログラムの場合、GCを完全に避ける必要はないかもしれません。 @nogccore.memory.GCAPIを賢く使ってパフォーマンス上の問題を避けることができます。 @nogcmainにつけずに、本当にGCアロケーションを許可したくない関数につけましょう。 プログラムの開始時にGC.disableを呼ばないでください。 かわりにクリティカルパスに入る前に呼んで、出る時にGC.enableを呼びましょう。 GC.collectで、ゲームレベルの間など、戦略的な点でコレクションを強制しましょう。

ソフトウェア開発におけるパフォーマンスチューニングと同じように、 実際に何が起こっているのかを可能な限り理解する必要があります。 core.memory.GCAPIの呼び出しを意味があると思った場所に追加するのはGCに無駄な仕事をさせ、 全く影響がない可能性があります。 ツールチェインの助けによって理解を深めることができます。

DRuntimeのGCオプション --DRT-gcopt=profile:1をコンパイルされたプログラム(コンパイラではありません!)に渡し、 チューンアップの助けにすることができます。 これはコレクションの合計数や合計コレクション時間のような役に立つGCプロファイリングデータを報告します。

デモンストレーションとして、gcstat.dは20個の値を整数の動的配列に追加します。

void main() {
    import std.stdio;
    int[] ints;
    foreach(i; 0 .. 20) {
        ints ~= i;
    }
    writeln(ints);
}

コンパイルしGCプロファイルスイッチ付きで実行します:

dmd gcstat.d
gcstat --DRT-gcopt=profile:1
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
        Number of collections:  1
        Total GC prep time:  0 milliseconds
        Total mark time:  0 milliseconds
        Total sweep time:  0 milliseconds
        Total page recovery time:  0 milliseconds
        Max Pause Time:  0 milliseconds
        Grand total GC time:  0 milliseconds
GC summary:    1 MB,    1 GC    0 ms, Pauses    0 ms <    0 ms

これは1つのコレクションを報告し、これはほぼ確実にプログラムの終了時に発生したものです。 ランタイムがGCを終了させると、現在の実装では、一般的にコレクションをトリガします。 これは主にコレクトしたオブジェクトでデストラクタを実行するためにおこなわれます。 DではGCでアロケートされたオブジェクトのデストラクタは必要ありません(将来の投稿のトピックです)。

DMDは、配列追加演算子のような言語機能に隠れたものも含めた、 すべてのGCアロケーションを表示するコマンドラインオプション-vgcをサポートしています。

デモンストレーションとして、inner.dをご覧ください:

void printInts(int[] delegate() dg)
{
    import std.stdio;
    foreach(i; dg()) writeln(i);
} 

void main() {
    int[] ints;
    auto makeInts() {
        foreach(i; 0 .. 20) {
            ints ~= i;
        }
        return ints;
    }

    printInts(&makeInts);
}

ここで、makeIntsは内部関数です。 非静的内部関数へのポインタは関数ポインタではなく、delegateです (コンテキストポインタと関数ポインタの組;内部関数がstaticの時は、 かわりにfunction型のポインタが作られます)。 この特殊なケースで、デリゲートは親スコープの変数を使います。 こちらが-vgcでコンパイルした時の出力です:

dmd -vgc inner.d
inner.d(11): vgc: operator ~= may cause GC allocation
inner.d(7): vgc: using closure causes GC allocation

ここからわかることはデリゲートがintsの状態、クロージャ(それ自身は型ではありません - 型はまだdelegateです)を運べるようにメモリがアロケートされる必要があるということです。 intsの宣言をmakeIntsのスコープの中に移動させてリコンパイルしてください。 クロージャのアロケーションがなくなるでしょう。 よりよい選択肢はprintIntsの宣言をこのように変えることです:

void printInts(scope int[] delegate() dg)

scopeを関数パラメータに追加するとパラメータ内の参照は逃れることができません。 言い換えると、グローバル変数にdgを代入したり、この関数から返すことはできなくなります。 これによってクロージャを作る必要がなくなり、アロケーションが行われなくなるという利点があります。 詳細については関数ポインタ、デリゲート、クロージャ関数パラメータストレージクラスのドキュメントを見てください。

要点

DのGCがJavaやC#のような言語のそれと大きく違うことを考えれば、異なるパフォーマンス特性を持つことは明らかです。 その上、Dのプログラムはほとんどすべてが参照型のため、 Javaのような言語で書かれたそれと比べて遥かに生み出すゴミが少ないという傾向があります。 初めてDのプロジェクトに取り組む時にこれを理解しておくと役立つでしょう。 経験豊富なJavaプログラマがコレクションの影響を軽減するためにとる戦略はここでは適用できません。

GCの停止が一切許容できないソフトウェアのクラスはたしかにありますが、それは間違いなく小さな集合です。 ほとんどのDプロジェクトはこのアーティクルの上にあるポイント#2のシンプルな軽減戦略から始めて、 パフォーマンスに影響する時、 場所で@nogccore.memory.GCを使うようコードを修正することができますし、そうでなければなりません。 ここで実演したコマンドラインオプションがそれが必要なエリアを嗅ぎだすのに役立ちます。

時がたつにつれ、Dプログラムのガベージコレクションをマイクロマネージするほうが簡単になります。 Dの標準ライブラリPhobosを可能な限り@nogcフレンドリーに作るための共同努力が進行中です。 例外の割り当てられ方を変えるWalterの提案のような言語機能の改善はこの作業をかなり速めるはずです。

このシリーズの将来の投稿では、GCヒープの外側にメモリをアロケートする方法、 それを同じプログラム内のGCアロケーションと一緒に使う方法、 @nogcコードで無効になる言語機能を補う方法、 オブジェクトデストラクタとGCの相互作用を扱う戦略などを見ていきます。

Vladimir Panteleev、Guillaume Piolat、Steven Schveighofferのこの記事の草稿への貴重なフィードバックに感謝します。

JavaおよびC#に関する誤った行を削除したり、複数のスレッドに関する情報を追加するために、この記事は改訂されました。


  1. おそらくイーグルスの同名の歌が記事タイトルの元ネタ。 ↩︎

  2. こちらから翻訳を引用 ↩︎