malloc+memset が calloc より遅いのはなぜですか? 質問する

malloc+memset が calloc より遅いのはなぜですか? 質問する

は割り当てられたメモリを初期化するという点でcallocと異なることが知られています。 では、メモリはゼロに設定されます。 では、メモリはクリアされません。malloccallocmalloc

なので、日常業務では を+callocとみなしています。ちなみに、趣味でベンチマーク用に以下のコードを書きました。mallocmemset

結果は混乱を招きます。

コード1:

#include<stdio.h>
#include<stdlib.h>
#define BLOCK_SIZE 1024*1024*256
int main()
{
        int i=0;
        char *buf[10];
        while(i<10)
        {
                buf[i] = (char*)calloc(1,BLOCK_SIZE);
                i++;
        }
}

コード1の出力:

time ./a.out  
**real 0m0.287s**  
user 0m0.095s  
sys 0m0.192s  

コード2:

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#define BLOCK_SIZE 1024*1024*256
int main()
{
        int i=0;
        char *buf[10];
        while(i<10)
        {
                buf[i] = (char*)malloc(BLOCK_SIZE);
                memset(buf[i],'\0',BLOCK_SIZE);
                i++;
        }
}

コード2の出力:

time ./a.out   
**real 0m2.693s**  
user 0m0.973s  
sys 0m1.721s  

memsetコード 2で を置き換えると、bzero(buf[i],BLOCK_SIZE)同じ結果になります。

私の質問は、なぜmalloc+ はmemsetよりもずっと遅いのかということですcalloc。どうすればcallocそれができるのでしょうか?

ベストアンサー1

短いバージョン: 常にcalloc()の代わりにを使用してくださいmalloc()+memset()。ほとんどの場合、それらは同じになります。場合によっては、calloc()は完全にスキップできるため、作業が少なくなりますmemset()。他の場合には、calloc()はごまかしてメモリを割り当てないこともできます。ただし、malloc()+memset()は常に全量の作業を実行します。

これを理解するには、メモリ システムについて簡単に説明する必要があります。

メモリのクイックツアー

ここには 4 つの主要な部分があります: プログラム、標準ライブラリ、カーネル、ページ テーブルです。プログラムについては既にご存知でしょうから...

やのようなメモリ アロケータはmalloc()calloc()主に小さな割り当て (1 バイトから数百 KB まで) を取得し、それらをより大きなメモリ プールにグループ化するために存在します。たとえば、16 バイトを割り当てると、はmalloc()最初にプールの 1 つから 16 バイトを取得しようとし、プールが空になるとカーネルにさらにメモリを要求します。ただし、質問されているプログラムは一度に大量のメモリを割り当てているため、malloc()calloc()カーネルに直接そのメモリを要求します。この動作のしきい値はシステムによって異なりますが、しきい値として 1 MiB が使用されているのを見たことがあります。

カーネルは、各プロセスに実際の RAM を割り当て、プロセスが他のプロセスのメモリに干渉しないようにする役割を担っています。これはメモリ保護と呼ばれ、 1990 年代から非常に一般的になっており、システム全体をダウンさせることなく 1 つのプログラムがクラッシュできる理由です。したがって、プログラムがより多くのメモリを必要とする場合、メモリを単に取得するのではなく、またはなどのシステム コールを使用してカーネルにメモリを要求しますmmap()sbrk()カーネルは、ページ テーブルを変更して各プロセスに RAM を提供します。

ページ テーブルは、メモリ アドレスを実際の物理 RAM にマップします。プロセスのアドレス (32 ビット システムでは 0x00000000 から 0xFFFFFFFF) は実際のメモリではなく、仮想メモリ内のアドレスです。プロセッサはこれらのアドレスを 4 KiB ページに分割し、ページ テーブルを変更することで各ページを異なる物理 RAM に割り当てることができます。ページ テーブルを変更できるのはカーネルだけです。

うまくいかない理由

256 MiB の割り当てが機能しない理由は次のとおりです。

  1. プロセスが呼び出されcalloc()、256 MiB を要求します。

  2. 標準ライブラリが呼び出されmmap()、256 MiB を要求します。

  3. カーネルは 256 MiB の未使用の RAM を見つけ、ページ テーブルを変更してそれをプロセスに渡します。

  4. 標準ライブラリは RAM をゼロにしてmemset()から戻りますcalloc()

  5. プロセスは最終的に終了し、カーネルは RAM を再利用して別のプロセスで使用できるようになります。

実際にどのように機能するか

上記のプロセスは機能しますが、この方法では実現しません。 3 つの大きな違いがあります。

  • プロセスがカーネルから新しいメモリを取得する場合、そのメモリはおそらく以前に他のプロセスによって使用されていたものです。これはセキュリティ リスクです。そのメモリにパスワード、暗号化キー、または秘密のサルサ レシピが含まれていたらどうなるでしょうか。機密データが漏洩しないようにするため、カーネルはプロセスに渡す前に必ずメモリを消去します。メモリをゼロにして消去することも考えられます。また、新しいメモリがゼロに設定されている場合は、それを保証するようにすると、mmap()返される新しいメモリが常にゼロに設定されていることが保証されます。

  • メモリを割り当てても、すぐには使用しないプログラムが多数あります。メモリは割り当てられても、まったく使用されないことがあります。カーネルはこれを認識して怠惰になります。新しいメモリを割り当てると、カーネルはページ テーブルにまったく触れず、プロセスに RAM を割り当てません。代わりに、カーネルはプロセス内のアドレス空間を見つけて、そこに何を置くべきかをメモし、プログラムが実際に使用する場合にそこに RAM を配置することを約束します。プログラムがそれらのアドレスから読み取りまたは書き込みを試行すると、プロセッサはページ フォールトをトリガーし、カーネルが介入してそれらのアドレスに RAM を割り当て、プログラムを再開します。メモリがまったく使用されない場合は、ページ フォールトは発生せず、プログラムは実際には RAM を取得しません。

  • 一部のプロセスはメモリを割り当て、それを変更せずに読み取ります。つまり、さまざまなプロセスにわたるメモリ内の多くのページが、から返された元のゼロで埋められる可能性がありますmmap()。これらのページはすべて同じであるため、カーネルはこれらすべての仮想アドレスを、ゼロで埋められた単一の共有 4 KiB メモリ ページを指すようにします。そのメモリに書き込もうとすると、プロセッサは別のページ フォールトをトリガーし、カーネルが介入して、他のプログラムと共有されていない新しいゼロ ページを提供します。

最終的なプロセスは次のようになります。

  1. プロセスが呼び出されcalloc()、256 MiB を要求します。

  2. 標準ライブラリが呼び出されmmap()、256 MiB を要求します。

  3. カーネルは 256 MiB の未使用のアドレス空間を見つけ、そのアドレス空間が現在何に使用されているかをメモして戻ります。

  4. 標準ライブラリは、 の結果がmmap()常にゼロで埋められる (または、実際に RAM を取得するとゼロで埋められる) ことを認識しているため、メモリには触れず、ページ フォールトは発生せず、RAM がプロセスに渡されることはありません。

  5. プロセスは最終的に終了し、RAM は最初から割り当てられていなかったため、カーネルは RAM を再利用する必要がありません。

を使用してmemset()ページをゼロにすると、memset()ページ フォールトがトリガーされ、RAM が割り当てられ、すでにゼロで埋められているにもかかわらずゼロにされます。これは膨大な追加作業であり、がやよりcalloc()も高速である理由を説明しています。結局メモリを使用することになった場合、はやよりも高速ですが、その差はそれほど大きくはありません。malloc()memset()calloc()malloc()memset()


これは必ずしもうまくいくとは限らない

すべてのシステムにページ化された仮想メモリがあるわけではないので、すべてのシステムがこれらの最適化を使用できるわけではありません。これは、80286 などの非常に古いプロセッサや、高度なメモリ管理ユニットには小さすぎる組み込みプロセッサにも当てはまります。

これは、割り当てが小さい場合には常に機能するとは限りません。割り当てが小さい場合、 はcalloc()カーネルに直接アクセスするのではなく、共有プールからメモリを取得します。一般に、共有プールには、 で使用されて解放された古いメモリからジャンク データが格納されている可能性があるfree()ため、calloc()そのメモリを取得して を呼び出してmemset()クリアすることができます。一般的な実装では、共有プールのどの部分がそのままで、まだゼロで満たされているかを追跡しますが、すべての実装でこれが実行されるわけではありません。

間違った答えを払拭する

オペレーティングシステムによっては、後でメモリをゼロにする必要がある場合に備えて、カーネルが空き時間にメモリをゼロにするかどうかが異なります。Linuxは事前にメモリをゼロにしません。Dragonfly BSDも最近カーネルからこの機能を削除した。ただし、他のカーネルの中には、事前にメモリをゼロにするものもあります。アイドル中にページをゼロにするだけでは、パフォーマンスの大きな違いを説明するのに十分ではありません。

このcalloc()関数は の特別なメモリ境界調整バージョンを使用していませんmemset()。そのため、それほど高速化されることはありません。memset()最近のプロセッサの実装のほとんどは、次のようになります。

function memset(dest, c, len)
    // one byte at a time, until the dest is aligned...
    while (len > 0 && ((unsigned int)dest & 15))
        *dest++ = c
        len -= 1
    // now write big chunks at a time (processor-specific)...
    // block size might not be 16, it's just pseudocode
    while (len >= 16)
        // some optimized vector code goes here
        // glibc uses SSE2 when available
        dest += 16
        len -= 16
    // the end is not aligned, so one byte at a time
    while (len > 0)
        *dest++ = c
        len -= 1

ご覧のとおり、memset()非常に高速であり、大きなメモリ ブロックの場合、これより優れたものはあまり得られません。

がすでにゼロになっているメモリをゼロにするということは、メモリが 2 回ゼロになることを意味しますが、それでは 2 倍のパフォーマンスの違いしか説明できません。ここでのパフォーマンスの違いははるかに大きくなります (私のシステムでは、とmemset()の間で 3 桁以上の差を計測しました)。malloc()+memset()calloc()

パーティーのトリック

malloc()10 回ループする代わりに、NULLを返すまでメモリを割り当てるプログラムを作成しますcalloc()

を追加するとどうなりますかmemset()?

おすすめ記事