mallocの動作を追いかける(環境変数編)

はじめに

malloc沼、ハマってますか?>挨拶

僕はどっぷりハマってもうイヤになりました。必要に応じてメモリを確保し、解放するという、一見単純な作業はとても奥が深いです。

特にglibc mallocはかなりアドホックなことをやっており、チューニング可能なパラメータがたくさんあります。それらのパラメタは、多くの一般的なアプリケーションでそこそこの性能が出るように選ばれていますが、アプリケーションによっては相性が悪く、性能が劣化する場合があります。その場合、mallocに関連するパラメタをいじる必要がでてきますが、これがなかなか面倒だし、設定したことでどんな変化が表れるかがわかりにくいことが多いです。

本稿では、環境変数から設定可能なmalloc関連のパラメタをいくつか紹介します。特に、環境変数を設定することで振る舞いがなるべくわかりやすく変わるサンプルコードを用意しました。

mallocがらみのパラメタについては、glibcの公式ドキュメントの3.2.3.7 Malloc Tunable Parametersに詳細な記述があるので参照してください。

また、mallocの動作についてはmalloc DOGAを見る例えば以下の記事を参照してください。

MALLOC_MMAP_THRESHOLD_

mmapで確保するチャンクの最小値を決めます。この値より大きなサイズのバッファはmmapで確保するようになります。

#include <cstdio>
#include <cstdlib>
#include <cstdint>

int main(void) {
  uint8_t *buf = (uint8_t *)malloc(1000);
  size_t *p = (size_t *)buf;
  if (*(p - 1) & 2) {
    printf("chunk in mmap\n");
  } else {
    printf("chunk in heap\n");
  }
  free(buf);
}

このサンプルコードでは1000バイトのメモリを確保しています。このままだとチャンクはヒープに取られます。

$ g++ test.cpp
$ ./a.out
chunk in heap

チャンクがヒープに取られたか、mmapされた領域に取られたかは、確保したバッファのアドレスの少し前に情報があります。

さて、MALLOC_MMAP_THRESHOLD_を1000にすると、このチャンクはmmapされた領域に確保されます。

$ MALLOC_MMAP_THRESHOLD_=1000 ./a.out
chunk in mmap

ここではmallocしているのは1000バイトですが、実際に確保されるバッファは管理領域も含めて1000バイトより大きくなります。

さらにスレッショルドを大きくすると、確保するメモリがスレッショルド以下になるので、またヒープに取られるようになります。

$ MALLOC_MMAP_THRESHOLD_=2000 ./a.out
chunk in heap

mmapは遅いので、ある程度大きなメモリを多数malloc/freeしたい場合はMALLOC_MMAP_THRESHOLD_を大きな値にすると性能向上につながる場合もあるようです(ただしメモリのフラグメンテーションとのトレードオフ)。

MALLOC_MMAP_MAX_

mmapで確保するチャンクの最大数を指定します。デフォルトは65536です。

こんなコードを書いてみます。

#include <cstdio>
#include <cstdlib>
#include <cstdint>

int main(void) {
  const int N = 10;
  uint8_t *buf[N];
  for (int i = 0; i < N; i++) {
    buf[i] = (uint8_t *)malloc(1024 * 1024);
    size_t *p = (size_t *)buf[i];
    if (*(p - 1) & 2) {
      printf("%d:chunk in mmap\n", i);
    } else {
      printf("%d:chunk in heap\n", i);
    }
  }
  for (int i = 0; i < N; i++) {
    free(buf[i]);
  }
}

多くの場合、1MBを超えるメモリをmallocしようとすると、mmapで確保されたメモリが割り当てられます。ここでは1MBのメモリを10個確保していますが、デフォルトでは全てmmapされたメモリにチャンクができます。

$ g++ test.cpp
$ ./a.out
0:chunk in mmap
1:chunk in mmap
2:chunk in mmap
3:chunk in mmap
4:chunk in mmap
5:chunk in mmap
6:chunk in mmap
7:chunk in mmap
8:chunk in mmap
9:chunk in mmap

しかし、例えばmmapで確保するチャンクの数を5個に制限すると、6個目からはヒープに取られます。

$ MALLOC_MMAP_MAX_=5 ./a.out
0:chunk in mmap
1:chunk in mmap
2:chunk in mmap
3:chunk in mmap
4:chunk in mmap
5:chunk in heap
6:chunk in heap
7:chunk in heap
8:chunk in heap
9:chunk in heap

mmapは遅いので、ある程度大きなメモリをmalloc/freeしたい場合はMALLOC_MMAP_MAX_を0にすると性能向上につながる場合もあるようです(ただしメモリのフラグメンテーションとのトレードオフ)。

MALLOC_PERTURB_

malloc、及びfreeの直後に、バッファをこの環境変数の値で埋めます。デバッグ用だそうです。

こんなコードを書いてみます。

#include <cstdio>
#include <cstdlib>
#include <cstdint>

int main(void) {
  uint8_t *buf = (uint8_t *)malloc(1024);
  printf("after malloc %u\n", (uint8_t)buf[0]);
  buf[0] = 0;
  free(buf);
  printf("after free   %u\n", (uint8_t)buf[0]);
}

バッファをmallocした直後と、0を代入した後でfreeした直後にバッファの先頭の値を表示しています。 mallocした直後の値は保証されていないと思いますが、多くの場合0が表示されると思います。 しかし、環境変数MALLOC_PERTURB_を指定すると、malloc/freeの直後にその値で埋められます。

$ g++ test.cpp
$ ./a.out
after malloc 0
after free   0
$ MALLOC_PERTURB_=1 ./a.out
after malloc 254
after free   1
$ MALLOC_PERTURB_=2 ./a.out
after malloc 253
after free   2

buf[0]に0を代入した後にもかかわらず、free後にはMALLOC_PERTURB_に設定した値になっていることがわかるかと思います。 malloc直後は254や253になっていますが、これは、0xffとXORをとった値で埋められるからです。

glibc mallocのソースを見るとこうなっています。

https://code.woboq.org/userspace/glibc/malloc/malloc.c.html#1947

static int perturb_byte;
static void
alloc_perturb (char *p, size_t n)
{
  if (__glibc_unlikely (perturb_byte))
    memset (p, perturb_byte ^ 0xff, n);
}
static void
free_perturb (char *p, size_t n)
{
  if (__glibc_unlikely (perturb_byte))
    memset (p, perturb_byte, n);
}

環境変数によりperturb_byteの値がセットされます。malloc直後はperturb_byte ^ 0xffが、free直後はperturb_byteの値そのものがmemsetされていることがわかります。

glibcのドキュメントで、環境変数が MALLOC_MMAP_PERTURB_と間違って記載されており(正しくはMALLOC_PERTURB_)、bugzillaにも報告があるものの一年近く放置されているところを見ると、誰も使っていないのかもしれません。

MALLOC_TOP_PAD_

mallocやfreeする際に、ヒープが足りなければ確保します。最初はヒープサイズはゼロです。ヒープの拡充はbrkというシステムコールによりOSに依頼しますが、これは遅いので、ちょこちょこbrkを呼ばずにすむように一番最初にmallocが呼ばれたときに「アリーナ」と呼ばれる大きな領域を「がこ」っとって来て、その中でやりくりするようにします。

しかし、それでも足りなくなるともう一度brkを呼び、さらに足りなくなるとbrkを呼び・・・という感じで、必要になるたびにbrkを呼びます。そのbrkを呼ぶ単位を制御するのがMALLOC_TOP_PAD_です。これはfreeした際にメモリをOSを返す際にも参照されます。

あまり大きくするとメモリ効率が悪くなり、あまり小さくするとbrkが多数呼ばれて性能劣化原因になります。それを見てみましょう。

#include <cstdio>
#include <cstdlib>
#include <cstdint>

int
main() {
  size_t size = 1024;
  const int N = 10000;
  uint8_t *buf[N];
  for (int i = 0; i < N; i++) {
    buf[i] = (uint8_t *)malloc(size);
  }
  for (int i = 0; i < N; i++) {
    free(buf[i]);
  }
}

単に1024バイトを1000回mallocしてfreeするだけのコードです。このコードで何回brkが呼ばれたか、straceでカウントしてみましょう。

$ g++ test.cpp
$ strace -c -e brk ./a.out
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0        80           brk
------ ----------- ----------- --------- --------- ----------------
100.00    0.000000                    80           total

デフォルトでは80回brkが呼ばれました。MALLOC_TOP_PAD_の値をいろいろ変えてどうなるか見てみましょう。

$ MALLOC_TOP_PAD_=1000 strace -c -e brk ./a.out
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
100.00    0.000016           0      2543           brk
------ ----------- ----------- --------- --------- ----------------
100.00    0.000016                  2543           total

$ MALLOC_TOP_PAD_=10000 strace -c -e brk ./a.out
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0       850           brk
------ ----------- ----------- --------- --------- ----------------
100.00    0.000000                   850           total

$ MALLOC_TOP_PAD_=100000 strace -c -e brk ./a.out
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0       105           brk
------ ----------- ----------- --------- --------- ----------------
100.00    0.000000                   105           total

$ MALLOC_TOP_PAD_=1000000 strace -c -e brk ./a.out
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
100.00    0.000659          47        14           brk
------ ----------- ----------- --------- --------- ----------------
100.00    0.000659                    14           total

MALLOC_TOP_PAD_の値が小さいとbrkが多数呼ばれ、大きいと回数が減ったのがわかるかと思います。

参照:https://qiita.com/angel_p_57/items/47138c90791adb5a1b83

まとめ

mallocの性能に影響する環境変数を紹介しました。他にもいろいろ、特にマルチスレッドまわりを制御する変数もあったりするのですが、僕はもうイヤになりました。この記事が将来、誰かがmalloc沼にハマった際に、そこを脱出するための一助になることを祈ります。