動的メモリ割り当てとメモリ管理


17

平均的なゲームでは、シーンには数百または数千のオブジェクトがあります。デフォルトのnew()を介して動的に銃の弾丸(弾丸)を含むすべてのオブジェクトにメモリを割り当てることは完全に正しいですか?

ダイナミックアロケーション用のメモリプールを作成する必要がありますか、これを気にする必要はありませんか?ターゲットプラットフォームがモバイルデバイスの場合はどうなりますか?

モバイルゲームでメモリマネージャーが必要ですか?ありがとうございました。

使用言語:C ++; 現在Windowsで開発されていますが、後で移植する予定です。


どの言語?
キロタン

@Kylotan:使用されている言語:C ++は現在Windowsで開発されていますが、後で移植する予定です。
文海。佐取

回答:


23

平均的なゲームでは、シーンには数百または数千の障害物があります。デフォルトのnew()を介して動的に銃の弾丸(弾丸)を含むすべてのオブジェクトにメモリを割り当てることは完全に正しいですか?

それはあなたが「正しい」という意味に本当に依存します。この用語を文字通り非常に正確に(そして暗黙の設計の正確性の概念を無視して)使用する場合、はい、完全に受け入れられます。プログラムがコンパイルされ、正常に実行されます。

最適に機能しない可能性がありますが、それでも出荷可能な楽しいゲームになるほど十分に機能する可能性があります。

ダイナミックアロケーション用のメモリプールを作成する必要がありますか、またはこれを気にする必要はありませんか?ターゲットプラットフォームがモバイルデバイスの場合はどうなりますか?

プロフィールと参照。たとえば、C ++では、ヒープ上の動的な割り当ては通常「遅い」操作です(ヒープ内を適切なサイズのブロックを探す必要があるため)。C#では、インクリメント以外のほとんどの操作を行わないため、通常は非常に高速な操作です。異なる言語実装は、メモリ割り当て、リリース時のフラグメンテーションなどに関して異なるパフォーマンス特性を持っています。

メモリプーリングシステムを実装すると、確かにパフォーマンスが向上します。また、モバイルシステムは通常、デスクトップシステムに比べて能力が低いため、デスクトップよりも特定のモバイルプラットフォームでより多くの利益を得ることができます。しかし、ここでもプロファイリングして確認する必要があります-現在、ゲームは遅いが、メモリの割り当て/リリースがプロファイラにホットスポットとして表示されない場合は、メモリの割り当てとアクセスを最適化するインフラストラクチャを実装するでしょうお金に見合うだけの価値はありません。

モバイルゲームでメモリマネージャーが必要ですか?ありがとうございました。

繰り返しになりますが、プロファイルして確認してください。ゲームは正常に実行されていますか?その後、心配する必要はありません。

すべての注意喚起はさておき、すべてに動的な割り当てを使用することは厳密には必要ではないため、潜在的なパフォーマンスの向上と、追跡して最終的に解放する必要があるメモリの割り当ての両方のために、それを避けることが有利になる可能性がありますコードを複雑にする可能性があるため、追跡して最終的にはリリースする必要があります。

特に、元の例では「弾丸」を引用しましたが、これは頻繁に作成および破壊される傾向があるものです-多くのゲームには多くの弾丸が含まれており、弾丸は高速で移動し、したがって寿命の終わりにすばやく到達するためです激しく!)。そのため、それらとそのようなオブジェクト(パーティクルシステムのパーティクルなど)にプールアロケータを実装すると、通常は効率が向上し、プール割り当ての使用を検討する最初の場所になる可能性があります。

メモリプールの実装を「メモリマネージャ」とは異なるものとみなすかどうかは不明です。メモリプールは比較的明確に定義された概念であるため、実装する場合にメリットがあると確信できます。 。「メモリマネージャ」は、その責任の点でもう少し曖昧です。そのため、必要かどうかは、「メモリマネージャ」が何をすると思うかによって異なります。

たとえば、メモリマネージャーをnew / delete / free / malloc / whateverの呼び出しをインターセプトし、割り当てたメモリの量、リークしたものなどの診断を提供するものと考える場合、それは便利です開発中にゲームのツールを使用して、リークをデバッグしたり、最適なメモリプールサイズを調整したりできます。


同意した。後で物事を変更できるようにコーディングします。疑わしい場合は、ベンチマークまたはプロファイル。
axel22

@ジョシュ:優れた答えのために+1。おそらく必要なのは、動的割り当て、静的割り当て、およびメモリプールの組み合わせです。ただし、ゲームのパフォーマンスは、これら3つの適切な組み合わせで私をガイドします。これは私の質問に対する承認済み回答の明確な候補です。ただし、他の人がどのような貢献をするのかを確認するために、しばらく質問を公開したい
文海。佐取

+1。優れた精緻化。パフォーマンスに関するほとんどすべての質問に対する答えは、常に「プロファイルと確認」です。最近のハードウェアは非常に複雑であるため、第一原理からのパフォーマンスについて考えることはできません。データが必要です。
11

@Munificent:コメントありがとうございます。そのため、目標はゲームの動作とストールを作り上げることです。開発の途中でパフォーマンスについて心配する必要はありません。それはすべてゲームの完了後に修正できます。
文海。佐取

これは、C#の割り当て時間の不公平な表現だと思います。たとえば、すべてのC#の割り当てには、同期ブロック、オブジェクトの割り当てなども含まれます。さらに、C ++のヒープは、 。
-DeadMG

7

ジョシュの優れた答えに追加することはあまりありませんが、これについてコメントします。

ダイナミックアロケーション用のメモリプールを作成する必要がありますか、またはこれを気にする必要はありませんか?

メモリープールとnew各割り当ての呼び出しの間には中間点があります。たとえば、配列に設定された数のオブジェクトを割り当て、それらにフラグを設定して後で「破棄」することができます。さらに割り当てる必要がある場合は、破棄フラグが設定されたものを上書きできます。この種のことは、new / delete(その目的のために2つの新しい関数があるので)よりも使用するのが少し複雑ですが、書くのは簡単で、大きな利益を得ることができます。


素敵な追加のために+1。はい、あなたは正しいです。それは、弾丸、パーティクル、エフェクトなどの単純なゲーム要素を管理するのに良い方法です。特にそれらの場合、メモリを動的に割り当てる必要はありません。
文海佐取

3

デフォルトのnew()を介して動的に銃の弾丸(弾丸)を含むすべてのオブジェクトにメモリを割り当てることは完全に正しいですか?

いいえ、もちろんありません。すべてのオブジェクトに適切なメモリ割り当てはありません。operator new()は動的割り当て用です。つまり、オブジェクトの有効期間が動的であるか、オブジェクトのタイプが動的であるため、動的に割り当てる必要がある場合にのみ適切です。オブジェクトのタイプとライフタイムが静的にわかっている場合は、静的に割り当てる必要があります。

もちろん、割り当てパターンに関する情報が多くなればなるほど、オブジェクトプールなどの専門のアロケーターを使用して、これらの割り当てをより速く行うことができます。ただし、これらは最適化であり、必要であることがわかっている場合にのみ作成する必要があります。


良い答えを得るために+1。したがって、一般化するための正しいアプローチは、開発の初期段階で、どのオブジェクトを静的に割り当てることができるかを計画することです。開発時に、絶対に動的に割り当てる必要があるオブジェクトのみを動的に割り当てる。最後に、メモリ割り当てのパフォーマンスの問題をプロファイリングおよび調整します。
文海。佐取

0

Kylotanの提案に似ていますが、可能な場合は下位のアロケーターレベルではなく、可能な場合はデータ構造レベルでこれを解決することをお勧めします。

以下はFoos、要素が一緒にリンクされた穴を持つ配列を繰り返し使用して割り当てと解放を回避する方法の簡単な例です(これを「アロケーター」レベルではなく「コンテナー」レベルで解決します)。

struct FooNode
{
    explicit FooNode(const Foo& ielement): element(ielement), next(-1) {}

    // Stores a 'Foo'.
    Foo element;

    // Points to the next foo available; either the
    // next used foo or the next deleted foo. Can
    // use SoA and hoist this out if Foo doesn't 
    // have 32-bit alignment.
    int next;
};

struct Foos
{
    // Stores all the Foo nodes.
    vector<FooNode> nodes;

    // Points to the first used node.
    int first_node;

    // Points to the first free node.
    int free_node;

    Foos(): first_node(-1), free_node(-1)
    {
    }

    const FooNode& operator[](int n) const
    {
         return data[n];
    }

    void insert(const Foo& element)
    {
         int index = free_node;
         if (index != -1)
         {
              // If there's a free node available,
              // pop it from the free list, overwrite it,
              // and push it to the used list.
              free_node = data[index].next;
              data[index].next = first_node;
              data[index].element = element;
              first_node = index;
         }
         else
         {
              // If there's no free node available, add a 
              // new node and push it to the used list.
              FooNode new_node(element);
              new_node.next = first_node;
              first_node = data.size() - 1;
              data.push_back(new_node);
         }
    }

    void erase(int n)
    {
         // If the node being removed is the first used
         // node, pop it from the used list.
         if (first_node == n)
              first_node = data[n].next;

         // Push the node to the free list.
         data[n].next = free_node;
         free_node = n;
    }
};

この効果のための何か:フリーリストを持つ一方向にリンクされたインデックスリスト。インデックスリンクを使用すると、削除された要素をスキップしたり、一定時間で要素を削除したり、一定時間挿入で空き要素を再利用/再利用/上書きしたりできます。構造を反復処理するには、次のようなことを行います。

for (int index = foos.first_node; index != -1; index = foos[index].next)
    // do something with foos[index]

ここに画像の説明を入力してください

そして、テンプレートを使用して上記の種類の「ホールのリンク配列」データ構造を一般化し、コピー割り当ての要件を回避するための新規および手動のdtor呼び出しを配置し​​、要素が削除されたときにデストラクタを呼び出し、前方反復子を提供することができます。私は非常に怠け者であるため、概念をより明確に説明するために例を非常にC風に保つことを選択しました。

とは言っても、この構造は、物を中心から出し入れした後、空間的な局所性が低下する傾向があります。その時点で、nextリンクを使用してベクターに沿って前後に移動し、以前は同じシーケンシャルトラバース内でキャッシュラインから削除されたデータをリロードできます(これは、再利用中に要素をシャッフルせずに一定時間削除できるデータ構造またはアロケーターでは避けられません)一定の時間を挿入し、パラレルビットセットやremovedフラグなどを使用せずに、中央からスペースを挿入します)。キャッシュの使いやすさを復元するには、次のようにコピーアクターとスワップメソッドを実装できます。

Foos(const Foos& other)
{
    for (int index = other.first_node; index != -1; index = other[index].next)
        insert(foos[index].element);
}

void Foos::swap(Foos& other)
{
     nodes.swap(other.nodes):
     std::swap(first_node, other.first_node);
     std::swap(free_node, other.free_node);
}

// ... then just copy and swap:
Foos(foos).swap(foos);

これで、新しいバージョンは再びトラバースしやすくなりました。別の方法は、インデックスの個別のリストを構造に保存し、定期的にソートすることです。別の方法は、ビットセットを使用して、使用されるインデックスを示すことです。これにより、常にビットセットを順番にトラバースすることができます(これを効率的に行うには、たとえばFFS / FFZを使用して一度に64ビットをチェックします)。ビットセットは最も効率的で邪魔にならず、32ビットのnextインデックスを必要とする代わりに、使用されるものと削除されるものを示すために要素ごとにパラレルビットのみを必要としますが、うまく書くのに最も時間がかかります(一度に1ビットをチェックしている場合は、トラバースを高速で実行します-占有インデックスの範囲を迅速に決定するには、FFS / FFZが一度に32ビット以上のセットまたはアンセットビットをすぐに見つける必要があります)。

このリンクされたソリューションは、一般的に実装が最も簡単で邪魔にならず(フラグFooを保存するために変更する必要はありませんremoved)、32ビットを気にしないでこのコンテナを一般化して任意のデータ型で動作させたい場合に役立ちます要素ごとのオーバーヘッド。

ダイナミックアロケーション用のメモリプールを作成する必要がありますか、またはこれを気にする必要はありませんか?ターゲットプラットフォームがモバイルデバイスの場合はどうなりますか?

ニーズは強力な言葉であり、レイトレーシング、画像処理、パーティクルシミュレーション、メッシュ処理などの非常にパフォーマンスが重要な分野での作業に偏っていますが、弾丸のような非常に軽い処理に使用される小さなオブジェクトを割り当てて解放するのは比較的非常に高価です汎用の可変サイズのメモリアロケーターに対するパーティクル。上記のデータ構造を1日または2日で一般化して必要なものを保存できるはずなので、このようなヒープの割り当て/割り当て解除のコストをすべての小さなものに対して支払うことから完全に排除する価値のある交換になると思います。割り当て/割り当て解除コストの削減に加えて、結果を横断する参照の局所性が向上します(つまり、キャッシュミスやページフォールトが減少します)。

JoshがGCについて言及したことに関して、私はC#のGC実装をJavaほど厳密には研究していませんが、GCアロケーターにはしばしば初期割り当てがありますこれは非常に高速です。これは、メモリを中央から解放できないシーケンシャルアロケータを使用しているためです(ほとんどスタックのように、中央からは削除できません)。次に、メモリをコピーし、以前に割り当てられたメモリ全体をパージすることにより、個々のオブジェクトを個別のスレッドで実際に削除できるようにするための高価なコストを支払います(リンクされた構造のようなものにデータをコピーしながらスタック全体を一度に破棄するなど)、ただし、別のスレッドで実行されるため、必ずしもアプリケーションのスレッドをそれほどストールするわけではありません。ただし、これには、間接レベルの追加という非常に大きな隠れたコストと、最初のGCサイクル後のLORの一般的な損失が伴います。ただし、割り当てを高速化するもう1つの戦略です。呼び出し元のスレッドでそれを安くしてから、別のスレッドで高価な作業を行います。そのため、オブジェクトを参照するのに1レベルではなく2レベルのインダイレクションが必要です。これは、最初に割り当てたときと最初のサイクルの後にメモリでシャッフルされるためです。

C ++で適用するのが少し簡単な、同様の方法でのもう1つの戦略は、メインスレッドでオブジェクトを解放することだけではありません。データ構造の最後に追加と追加と追加を続けるだけで、途中から物を削除することはできません。ただし、削除する必要があるものにマークを付けます。次に、別のスレッドが、削除された要素なしで新しいデータ構造を作成する高価な作業を処理し、次に新しいものを古いものとアトミックに交換します。たとえば、要素の割り当てと解放の両方のコストの要素を削除する要求がすぐに満たされる必要がないと仮定できる場合は、スレッドを分離します。これにより、スレッドに関する限り、解放が安くなるだけでなく、割り当てが安くなります。真ん中からの削除のケースを処理する必要のない、はるかに単純でより暗いデータ構造を使用できるためです。必要なのはコンテナのようなものですpush_back挿入のためのclear関数、すべての要素を削除swapし、削除された要素を除く新しいコンパクトなコンテナと内容を交換するための関数。変異に関する限りはこれで終わりです。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.