メインコンテンツまでスキップ
に最終更新

メモリ制御戦略

Doris Allocator は、システム内で大きなメモリブロックを確保するための統一された窓口です。適切なタイミングでメモリ割り当ての制限に介入し、効率的で制御可能なメモリ確保を実現します。

Doris MemoryArbitrator は、Doris BE プロセスのメモリ使用量をリアルタイムで監視し、メモリ状態を定期的に更新して、メモリ関連の統計情報のスナップショットを収集するメモリアービトレーターです。

Doris MemoryReclamation は、利用可能なメモリが不足するとメモリ GC を起動してメモリの一部を回収し、クラスター上で実行される大半のタスクの安定性を確保するメモリ回収機構です。

Doris Allocator​

メモリ管理の概要

Allocator はシステムにメモリを要求し、MemTracker を使用して、その過程でのメモリの確保量と解放量を追跡します。オペレーターをバッチ単位で実行するために必要な大量のメモリは、さまざまなデータ構造によって管理されます。

クエリ実行中の大きなメモリブロックの割り当ては、主に Arena、HashTable、PODArray のデータ構造によって管理されます。Allocator はこれらの統一されたメモリインターフェースとして機能し、メモリ管理の統一とローカルでのメモリ再利用を実現します。

メモリアロケーター

Allocator は汎用メモリアロケーターを使用してメモリを確保します。Jemalloc と TCMalloc の選定にあたり、以前の Doris の高並列度テストでは、TCMalloc の CentralFreeList の Spin Lock がクエリ実行時間全体の 40% を占めました。積極的なメモリのデコミットを無効にすると性能は効果的に向上しますが、大量のメモリを無駄に消費します。そのため、TCMalloc のキャッシュを定期的に回収する専用スレッドが必要です。Jemalloc は高並列度の環境で TCMalloc より高い性能を発揮し、成熟していて安定しています。Doris 1.2.2 で Jemalloc に切り替えました。調整後は、ほとんどのシナリオで TCMalloc と同等の性能を維持しながら、メモリ使用量が減少しました。高並列度のシナリオでも性能が大幅に向上しました。

Arena​

Arena はメモリブロックのリストを保持するメモリプールです。そこからメモリを割り当てて割り当て要求に応えることで、システムへのメモリ要求回数を減らし、性能を向上させます。このメモリブロックは Chunk と呼ばれ、メモリプールのライフサイクル全体にわたって存在し、通常はクエリのライフサイクルと一致するプールの破棄時にまとめて解放されます。メモリアラインメントにも対応し、主に Shuffle 処理中のシリアライズまたはデシリアライズされたデータや、HashTable 内のシリアライズされた Key などの保存に使用されます。

Chunk の初期サイズは 4096 バイトで、内部のカーソルによって割り当て済みメモリの位置を記録します。現在の Chunk の残りの容量ではメモリ要求を満たせない場合、新しい Chunk を確保してリストに追加します。システムへのメモリ要求回数を減らすため、現在の Chunk が 128M 未満の場合、新しく確保する Chunk のサイズは毎回倍増します。現在の Chunk が 128M を超える場合は、現在のメモリ要求を満たすことを前提として、新しい Chunk のサイズを最大 128M に抑え、過剰なメモリの浪費を避けます。デフォルトでは、それ以前の Chunk は後続の割り当てに使用されません。

HashTable​

Doris の HashTable は、主に Hash Join、集約、集合演算、ウィンドウ関数で使用されます。主に使用される PartitionedHashTable は最大 16 個のサブ HashTable を持ち、二つの HashTable の並列マージをサポートします。各サブ Hash Join は独立して拡張されるため、全体のメモリ使用量を減らし、拡張時の遅延も分散できると見込まれます。

HashTable が 8M 未満の場合は 4 倍に拡張されます。8M を超える場合は 2 倍に拡張されます。HashTable が 2G 未満の場合、拡張係数は 50% です。つまり、HashTable の使用率が 50% に達すると拡張が開始されます。2G を超えると、拡張係数は 75% に調整されます。メモリの過剰な浪費を避けるため、通常は HashTable を構築する前にデータ量に応じて事前に拡張します。また Doris では、集約シナリオで PHmap を使用して並列処理性能を最適化するなど、シナリオごとに異なる HashTable を設計しています。

PODArray​

PODArray は POD 型の動的配列です。std::vector と異なり、要素を初期化しません。std::vector の一部のインターフェースをサポートし、メモリアラインメントと 2 倍単位の拡張にも対応しています。PODArray を破棄するときは各要素のデストラクターを呼び出さず、メモリブロック全体を直接解放します。主に String などの列のデータ保存に使用されるほか、関数の計算や式によるフィルタリングにも広く使用されます。

メモリの再利用​

Doris では実行層でメモリを広く再利用しており、目立つメモリのホットスポットにはほぼ対処しています。たとえば、データブロックである Block は Query の実行全体を通じて再利用されます。Shuffle の Sender 側では、データを受信するための Block を常に一つ保持し、RPC 送信には別の Block を交互に使用します。また、ストレージ層では Tablet の読み取り時に述語列を再利用し、データの読み取り、フィルタリング、上位層の Block へのコピー、Clear を行います。Aggregate Key テーブルのロード時には、キャッシュされたデータの MemTable が一定のサイズに達すると、事前集約後に縮小して書き込みを続けます。

さらに Doris は、データ Scan の開始前に、Scanner とスレッドの数に基づいて Free Block をまとめて事前割り当てします。Scanner がスケジュールされるたびに、そこから Block を取得し、データを読み取るためにストレージ層へ渡します。読み取りが完了すると Block はプロデューサーキューに置かれ、上位層のオペレーターによる消費と後続の計算に使用されます。上位層のオペレーターはデータをコピーした後、次回の Scanner のスケジューリングに備えて Block を Free Block に戻します。これによりメモリが再利用されます。データ Scan が完了すると、メモリの確保と解放が異なるスレッドで行われることによる余分なオーバーヘッドを避けるため、Free Block は事前割り当てを行ったスレッドでまとめて解放されます。Free Block の数は、データ Scan の並列度もある程度制御します。

メモリ GC​

Doris BE は、プロセスの物理メモリ使用量と現在システムで利用可能なメモリ量を定期的に取得し、すべてのクエリ、ロード、コンパクションタスクの MemTracker のスナップショットを収集します。BE プロセスのメモリが上限を超えるか、システムで利用可能なメモリが不足すると、Doris はキャッシュを解放し、一部のクエリやロードを終了してメモリを解放します。この処理は専用の GC スレッドによって定期的に実行されます。

メモリ GC

Doris BE プロセスのメモリが SoftMemLimit(デフォルトではシステムの総メモリの 81%)を超えるか、システムに残っている利用可能なメモリが Warning ウォーターマーク(通常は 3.2GB 以下)を下回ると、Minor GC が起動します。このとき Allocator がメモリを割り当てる際にクエリが一時停止され、キャッシュ内のデータが強制的にロードされ、一部の Data Page Cache と期限切れの Segment Cache が解放されます。解放したメモリがプロセスのメモリの 10% 未満で、クエリのメモリ超過割り当てが有効な場合は、メモリ超過割り当て率の高いクエリをキャンセルします。これは、プロセスのメモリの 10% が解放されるか、キャンセル可能なクエリがなくなるまで続きます。その後、システムのメモリ状態の取得間隔と GC 間隔を短くします。残りのメモリが確認されると、ほかのクエリは実行を再開します。

BE プロセスのメモリが MemLimit(デフォルトではシステムの総メモリの 90%)を超えるか、システムに残っている利用可能なメモリが Low ウォーターマーク(通常は 1.6GB 以下)を下回ると、Full GC が起動します。上記の処理に加え、キャッシュ内のデータを強制的にフラッシュする際にはロードも一時停止され、すべての Data Page Cache とその他のキャッシュの大部分が解放されます。解放したメモリが 20% 未満の場合は、すべてのクエリとロードの MemTracker のリストを所定の戦略で検索し、メモリ使用量の多いクエリ、メモリ超過割り当て率の高いロード、メモリ使用量の多いロードを順にキャンセルします。これは、プロセスのメモリの 20% が解放されるまで続きます。その後、システムのメモリ状態の取得間隔と GC 間隔を長くし、ほかのクエリとロードは実行を再開します。GC にかかる時間は通常、数百 us から数十 ms です。

メモリ上限とウォーターマークの計算方法​

  • プロセスメモリ上限 MemLimit = be.conf/mem_limit * PhysicalMemory。デフォルトはシステムの総メモリの 90% です。詳細については、原文に参照先が記載されていません。

  • プロセスメモリのソフト上限 SoftMemLimit = be.conf/mem_limit * PhysicalMemory * be.conf/soft_mem_limit_frac。デフォルトはシステムの総メモリの 81% です。

  • システムで利用可能な残りのメモリの Low ウォーターマーク LowWaterMark = be.conf/max_sys_mem_available_low_water_mark_bytes。デフォルト値は -1 で、その場合は LowWaterMark = min(PhysicalMemory - MemLimit, PhysicalMemory * 0.05) です。64G のメモリを搭載したマシンでは、LowWaterMark は 3.2 GB をわずかに下回ります(PhysicalMemory の実際の値は 64G より小さいことが多いためです)。

  • システムで利用可能な残りのメモリの Warning ウォーターマーク WarningWaterMark = 2 * LowWaterMark。64G のメモリを搭載したマシンでは、WarningWaterMark のデフォルト値は 6.4 GB をわずかに下回ります。

システムで利用可能な残りのメモリ量の計算​

エラーメッセージに表示される利用可能なメモリが Low ウォーターマークを下回る場合も、プロセスのメモリ上限超過として扱われます。システムで利用可能なメモリ量は、MemAvailable の値であり、/proc/meminfo から取得します。MemAvailable が不足している状態でメモリを要求し続けると、std::bad_alloc が返されるか、BE プロセスで OOM が発生する可能性があります。プロセスのメモリ統計の更新と BE のメモリ GC には一定の遅延があるため、OOM をできるだけ防ぐ目的で、メモリの一部を Low ウォーターマークとして確保します。

MemAvailable は、現在の空きメモリ、バッファー、キャッシュ、メモリ断片化などの要因を基に、スワップをできるだけ発生させずにユーザープロセスへ提供できるメモリの総量として OS が示す値です。簡単な計算式は MemAvailable = MemFree - LowWaterMark + (PageCache - min(PageCache / 2, LowWaterMark)) であり、available の値として free コマンドに表示されます。詳細については、以下を参照してください。

MemAvailable が MemFree、Buffers、Cached より大幅に少ない理由

Linux MemAvailable

Low ウォーターマークのデフォルト値は 3.2G(1.6G は 2.1.5 より前のバージョンでの値)で、MemTotal、vm/min_free_kbytes、confg::mem_limit、config::max_sys_mem_available_low_water_mark_bytes に基づいて計算され、メモリの過剰な浪費を防ぎます。このうち MemTotal はシステムの総メモリ量で、値は /proc/meminfo から取得します。vm/min_free_kbytes はメモリ GC 処理のために OS が予約するバッファーで、通常は 0.4% から 5% です。一部のクラウドサーバーでは vm/min_free_kbytes が 5% に設定されている場合があり、その場合、システムで利用可能なメモリが実際より少なく見えます。64G を超えるメモリを搭載したマシンでは、config::max_sys_mem_available_low_water_mark_bytes を増やすと Full GC 用により多くのメモリを確保でき、逆に減らすとメモリを可能な限り活用できます。