web-dev-qa-db-ja.com

プロセスのメモリ割り当てが遅いのはなぜですか?

私は仮想メモリの仕組みを比較的よく知っています。すべてのプロセスメモリはページに分割され、仮想メモリのすべてのページが実メモリ内のページまたはスワップファイル内のページにマップされます。または、物理ページがまだ割り当てられていない新しいページになる場合があります。 OSは、アプリケーションがmallocでメモリを要求するときではなく、アプリケーションが実際に割り当てられたメモリからすべてのページにアクセスするときにのみ、新しいページをオンデマンドで実際のメモリにマップします。しかし、私はまだ質問があります。

Linux perfツールを使用してアプリをプロファイリングしているときに、これに気付きました。

enter image description here

カーネル関数にかかる時間の約20%があります:clear_page_orig__do_page_faultおよびget_page_from_free_list。これは、このタスクで予想したよりもはるかに多く、いくつかの調査を行いました。

いくつかの小さな例から始めましょう:

#include <stdlib.h>
#include <string.h>
#include <stdio.h>

#define SIZE 1 * 1024 * 1024

int main(int argc, char *argv[]) {
  int i;
  int sum = 0;
  int *p = (int *) malloc(SIZE);
  for (i = 0; i < 10000; i ++) {
    memset(p, 0, SIZE);
    sum += p[512];
  }
  free(p);
  printf("sum %d\n", sum);
  return 0;
}

memsetはメモリにバインドされた処理の一部であると想定します。この場合、メモリの小さなチャンクを1回割り当て、それを何度も再利用します。このプログラムを次のように実行します。

$ gcc -O1 ./mem.c && time ./a.out

-O1clang-O2はループを完全に排除し、瞬時に値を計算します。

結果は次のとおりです。ユーザー:0.520秒、システム:0.008秒。 perfによると、この時間の99%はmemsetlibcにあります。したがって、この場合、書き込みパフォーマンスは約20ギガバイト/秒であり、これは私のメモリの理論上のパフォーマンス12.5 Gb /秒を超えています。これは、L3 CPUキャッシュが原因であるようです。

テストを変更して、ループ内のメモリの割り当てを開始します(コードの同じ部分を繰り返すことはしません)。

#define SIZE 1 * 1024 * 1024
for (i = 0; i < 10000; i ++) {
  int *p = (int *) malloc(SIZE);
  memset(p, 0, SIZE);
  free(p);
}

結果はまったく同じです。 freeは実際にはOSのメモリを解放するのではなく、プロセス内の空きリストに追加するだけだと思います。そして、次の反復でのmallocは、まったく同じメモリブロックを取得します。そのため、目立った違いはありません。

SIZEを1メガバイトから増やしてみましょう。実行時間は少しずつ増加し、10メガバイト近くで飽和します(10メガバイトと20メガバイトの間で違いはありません)。

#define SIZE 10 * 1024 * 1024
for (i = 0; i < 1000; i ++) {
  int *p = (int *) malloc(SIZE);
  memset(p, 0, SIZE);
  free(p);
}

時間の表示:ユーザー:1.184秒、システム:0.004秒。 perfは依然として99%の時間はmemsetにあると報告していますが、スループットは約8.3 Gb /秒です。その時点で、多かれ少なかれ何が起こっているのか理解しています。

メモリブロックサイズを増やし続けると、ある時点(私にとっては35 Mb)で実行時間が劇的に増加します。ユーザー:0.724秒、システム:3.300秒。

#define SIZE 40 * 1024 * 1024
for (i = 0; i < 250; i ++) {
  int *p = (int *) malloc(SIZE);
  memset(p, 0, SIZE);
  free(p);
}

perfによると、memsetは時間の18%しか消費しません。

enter image description here

明らかに、メモリはOSから割り当てられ、各ステップで解放されます。前述のとおり、OSは割り当てられた各ページを使用前にクリアする必要があります。つまり、27.3%のclear_page_origは異常に見えません:clear memは4秒* 0.273≈1.1秒です。3番目の例と同じです。 memsetは17.9%かかりました。これは≈700ミリ秒につながります。これはclear_page_orig(最初と2番目の例)。

理解できないこと—最後のケースがメモリのmemset + L3キャッシュのmemsetの2倍遅いのはなぜですか?何かできますか?

結果は、ネイティブMac OS、Vmwareの下のUbuntu、およびAmazon c4.largeインスタンスで(わずかな違いはありますが)再現可能です。

また、2つのレベルで最適化の余地があると思います。

  • OSレベル。 OSが以前に属していた同じアプリケーションにページを返すことがわかっている場合、OSはそれをクリアできません。
  • CPUレベル。 CPUは、ページが以前は解放されていたことがわかっている場合、メモリ内のページをクリアできません。キャッシュ内でそれをクリアし、キャッシュ内で何らかの処理を行った後にのみメモリに移動することができます。
27
homm

ここで何が起こっているかは、いくつかの異なるシステムに関係しているので少し複雑ですが、コンテキストスイッチのコストとは関係ありません。プログラムでシステムコールをほとんど行わない( strace を使用してこれを確認する)。

最初に、malloc実装の一般的な動作方法に関するいくつかの基本原則を理解することが重要です。

  1. ほとんどのmalloc実装は、初期化中にsbrkまたはmmapを呼び出すことにより、OSから大量のメモリを取得します。取得されるメモリの量は、一部のmalloc実装で調整できます。メモリが取得されると、通常、メモリはさまざまなサイズのクラスに分割され、データ構造に配置されるため、プログラムがmalloc(123)などを使用してメモリを要求すると、malloc実装はこれらの要件に一致するメモリの一部。
  2. freeを呼び出すと、メモリは空きリストに戻され、その後のmallocの呼び出しで再利用できます。一部のmalloc実装では、これがどのように機能するかを正確に調整できます。
  3. 大きなメモリチャンクを割り当てる場合、ほとんどのmalloc実装は、大量のメモリの呼び出しをmmapシステムコールに直接渡すだけで、一度にメモリの「ページ」を割り当てます。ほとんどのシステムでは、1ページのメモリは4096バイトです。
  4. 関連して、ほとんどのOSはmmapまたはsbrkを介してメモリを要求したプロセスにページを渡す前に、メモリのページをクリアしようとします。これが、perf出力にclear_page_origの呼び出しが表示される理由です。この関数は、メモリのページに0を書き込もうとしています。

現在、これらの原則は、多くの名前を持っていますが、一般に「デマンドページング」と呼ばれている別のアイデアと交差しています。 「デマンドページング」が意味することは、ユーザープログラムがOSからメモリのチャンクを要求すると(たとえば、mmapを呼び出すことにより)、メモリはプロセスの仮想アドレス空間に割り当てられますが、物理的なRAMまだそのメモリをサポートしています。

デマンドページングプロセスの概要は次のとおりです。

  1. 500MBのRAMを割り当てるためにmmapと呼ばれるプログラム。
  2. カーネルは、プロセスのアドレススペース内のアドレスの領域を500 MBのRAM要求されたものにマップします。それは、物理的な "少数"(OS依存)ページ(通常4096バイト)をマップしますRAMこれらの仮想アドレスをバックアップします。
  3. ユーザープログラムは、書き込みによってメモリへのアクセスを開始します。
  4. 最終的に、ユーザープログラムは有効なアドレスにアクセスしますが、物理的なRAMをサポートしていません。
  5. これにより、CPUでページ違反が発生します。
  6. カーネルは、プロセスが有効なアドレスにアクセスしていることを確認することでページフォールトに応答しますが、物理的なRAMがそれをサポートしていないアドレスです。
  7. その後、カーネルはRAMを見つけてその領域に割り当てます。他のプロセスのメモリを最初にディスクに書き込む必要がある場合(「スワップアウト」)は、これが遅くなる可能性があります。

最後のケースでパフォーマンスの低下が見られる最も可能性の高い理由は次のとおりです。

  1. カーネルは、40 MBの要求を満たすために配布できるメモリのゼロ化されたページが不足しているため、perf出力によって証明されるように、メモリを何度もゼロ化しています。
  2. まだマップされていないメモリにアクセスすると、ページフォルトが発生します。 10MBではなく40MBにアクセスしているため、マップする必要のあるメモリのページが増えると、ページフォールトがさらに発生します。
  3. 別の答えが指摘しているように、memsetはO(n)であり、書き込む必要のあるメモリが多いほど、時間がかかります。
  4. 可能性は低いですが、40 mbはそれほど多くないのでRAM最近はですが、十分なRAMがあることを確認するために、システムの空きメモリの量を確認してください。

アプリケーションがパフォーマンスに非常に敏感な場合は、代わりにmmapを直接呼び出し、次のことができます。

  1. MAP_POPULATEフラグを渡すと、すべてのページフォールトが事前に発生し、すべての物理メモリがマップされます。アクセスすると、ページフォールトのコストはかかりません。
  2. MAP_UNINITIALIZEDフラグを渡して、プロセスに配布する前にメモリのページをゼロにしないようにします。このフラグの使用はセキュリティ上の問題であり、このオプションを使用することの影響を完全に理解していない限り使用しないでください。機密情報を保存するために他の無関係なプロセスによって使用されたメモリのページがプロセスに発行される可能性があります。また、このオプションを許可するにはカーネルをコンパイルする必要があることにも注意してください。ほとんどのカーネル(AWS Linuxカーネルなど)には、このオプションがデフォルトで有効になっていません。このオプションは、ほぼ間違いなく使用しないでください

このレベルの最適化はほとんどの場合間違いであることに注意してください。ほとんどのアプリケーションには、ページフォールトコストの最適化を伴わない最適化のためのはるかに低いぶら下がり果物があります。実際のアプリケーションでは、次のことをお勧めします。

  1. 本当に必要でない限り、大きなメモリブロックでのmemsetの使用を避けます。ほとんどの場合、同じプロセスで再利用する前にメモリをゼロにする必要はありません。
  2. 同じメモリのチャンクを何度も割り当てたり解放したりすることを避けます。おそらく、単に大きなブロックを前もって割り当て、後で必要に応じてそれを再利用できます。
  3. アクセス時のページ不在のコストが本当にパフォーマンスに悪影響を与える場合は(可能性が低い)、上記のMAP_POPULATEフラグを使用します。

質問がある場合はコメントを残してください。必要に応じて、この投稿を編集して少し拡張します。

27
Joe Damato

確かではありませんが、ユーザーモードからカーネルへのコンテキストの切り替えのコストに賭けても構わないと思います。 memsetにもかなりの時間がかかります-O(n)になることを覚えておいてください。

更新

Freeは実際にはOSのメモリを解放するのではなく、プロセス内の空きリストに追加するだけだと思います。そして、次の反復でのmallocは、まったく同じメモリブロックを取得します。そのため、目立った違いはありません。

これは原則として正しいです。従来のmalloc実装は、単一リンクリストにメモリを割り当てます。 freeは、割り当てが使用されなくなったことを示すフラグを設定するだけです。時間が経つにつれ、mallocは、十分な空きブロックを最初に見つけたときに再割り当てします。これは十分に機能しますが、断片化につながる可能性があります。

現在、より洗練された実装がいくつかあります。 このWikipediaの記事 を参照してください。

6
Charlie Martin