web-dev-qa-db-ja.com

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

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

したがって、日常業務では、callocmalloc + memsetと見なします。ちなみに、楽しみのために、ベンチマーク用に次のコードを作成しました。

結果は紛らわしいです。

コード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  

コード2のmemsetbzero(buf[i],BLOCK_SIZE)に置き換えると、同じ結果が生成されます。

私の質問は:なぜmalloc + memsetcallocよりもずっと遅いのですか?どうすればcallocできますか?

244
kingkai

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

これを理解するには、メモリシステムの短いツアーが必要です。

記憶のクイックツアー

ここには、プログラム、標準ライブラリ、カーネル、ページテーブルの4つの主要部分があります。あなたはすでにあなたのプログラムを知っているので...

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

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

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

仕組み

256 MiBを割り当てると、notが機能します。

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

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

  3. カーネルは256 MiBの未使用のRAMを検出し、ページテーブルを変更することでプロセスにそれを提供します。

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

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

実際の仕組み

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

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

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

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

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

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

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

  3. カーネルは256 MiBの未使用アドレス空間を検出し、はそのアドレス空間が現在何に使用されているかを記録し、返します。

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

  5. プロセスは最終的に終了し、最初に割り当てられたことがないため、カーネルは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()は非常に高速であり、メモリの大きなブロックに対しては何も改善されません。

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

パーティートリック

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

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

436
Dietrich Epp

多くのシステムでは、予備の処理時間に、OSが空きメモリを自動的にゼロに設定し、calloc()に対して安全とマークするため、calloc()を呼び出すと、空きメモリがすでにゼロになっている可能性がありますあなたに与える記憶。

12
Chris Lutz

一部のプラットフォームの一部のモードでは、mallocはメモリを通常はゼロ以外の値に初期化してから返すため、2番目のバージョンではメモリを2回初期化できます。

1
Stewart