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


256

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

だから、日常の仕事で、私は考えるcallocようmalloc+ 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+がmemsetこれよりはるかに遅いのですcallocか?どうすればそれができcallocますか?

回答:


455

ショートバージョン:の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を割り当ててプログラムを再開します。メモリを使用しない場合、ページフォールトは発生せず、プログラムは実際には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は事前にメモリをゼロにしません。DragonflyBSDも最近、この機能をカーネルから削除しました。ただし、他の一部のカーネルは、事前にメモリをゼロにします。いずれにしても、パフォーマンスの大きな違いを説明するには、ページをゼロに調整するだけでは十分ではありません。

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()との間で3桁以上測定しましたcalloc())。

パーティートリック

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

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


7
@Dietrich:同じゼロで埋められたページをcallocに何度も割り当てるOSに関するディートリッヒの仮想メモリの説明は、簡単に確認できます。割り当てられたすべてのメモリページにジャンクデータを書き込むループを追加するだけです(500バイトごとに1バイトを書き込むだけで十分です)。システムは両方のケースで実際に別のページを割り当てる必要があるため、全体的な結果ははるかに近くなります。
クリス

1
@kriss:実際、大多数のシステムでは4096ごとに1バイトで十分です
Dietrich Epp

実際にcalloc()は、多くの場合、malloc実装スイートの一部であるため、からメモリを取得するときに呼び出されないように最適化されてます。bzerommap
mirabilos 2014年

1
編集ありがとうございます、それは私が考えていたものとほとんど同じです。早い段階で、malloc + memsetの代わりに常にcallocを使用すると述べました。1.デフォルトにmallocを指定してください2.バッファーの小さな部分をゼロにする必要がある場合は、その部分をmemset 3.それ以外の場合はcallocを使用してください。特に、サイズ全体をmalloc + memsetしないでください(そのためにcallocを使用します)。valgrindや静的コードアナライザー(すべてのメモリが突然初期化される)などを妨げるため、デフォルトですべてをcallocにしないでください。それ以外は問題ないと思います。
の月の従業員

5
速度は関係ありませんが、callocバグが発生しにくくなっています。それはどこでありlarge_int * large_int、オーバーフローにつながるcalloc(large_int, large_int)戻りNULLますが、malloc(large_int * large_int)あなたが返されるメモリブロックの実際のサイズがわからないよう、未定義の動作です。
砂丘

12

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


2
本気ですか?これを行うシステムは?ほとんどのOSはアイドル時にプロセッサをシャットダウンするだけで、メモリに書き込むとすぐに割り当てられたプロセスのメモリをオンデマンドでゼロにしたと思いました(ただし、メモリを割り当てるときはそうではありません)。
ディートリッヒエップ

@Dietrich-わかりません。私は一度それを聞いた、そしてそれはcalloc()より効率的にするための合理的な(そして合理的に単純な)方法のように見えた。
Chris Lutz

@Pierreten-特定のcalloc()最適化に関する良い情報が見つからず、OPのlibcソースコードを解釈したくありません。この最適化が存在しない/機能しないことを示すために何かを調べることができますか?
Chris Lutz、

13
@Dietrich:FreeBSDはアイドル時間にページをゼロで埋めるようになっています:vm.idlezero_enable設定を参照してください。
Zan Lynx

1
@DietrichEppはネクロに申し訳ありませんが、たとえばWindowsはこれを行います。
Andreas Grapentin 2014

1

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

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.