1. ホーム
  2. c

[解決済み】malloc+memsetはcallocより遅いのはなぜ?

2022-04-01 18:02:54

質問

知られているのは 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  

置き換え memsetbzero(buf[i],BLOCK_SIZE) のコード2でも同じ結果になります。

質問です。 なぜ malloc + memset よりもはるかに遅いので calloc ? どうして calloc ということです。

どのように解決するのですか?

簡単に言うと 常に calloc() の代わりに malloc()+memset() . ほとんどの場合、それらは同じになります。 場合によっては calloc() をスキップすることができるため、より少ない作業で memset() を完全に削除します。 それ以外の場合は calloc() は、メモリを割り当てないというズルもできるのです! しかし malloc()+memset() は常に全量を処理します。

このことを理解するためには、メモリシステムを少し見学する必要があります。

メモリのクイックツアー

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

のようなメモリアロケータは malloc()calloc() は、ほとんどの場合、小さな割り当て(1バイトから数百キロバイトまで)を、より大きなメモリプールにまとめるために存在します。 たとえば、16バイトを割り当てる場合。 malloc() は、まずプールの1つから16バイトを取り出そうとし、プールが枯渇したときにカーネルにさらなるメモリを要求します。 しかし、ご質問のプログラムは、一度に大量のメモリに対して割り当てを行うため malloc()calloc() は、そのメモリをカーネルから直接要求するだけです。 この動作の閾値はシステムに依存しますが、私は1MiBが閾値として使われているのを見たことがあります。

カーネルは、各プロセスに実際のRAMを割り当て、プロセスが他のプロセスのメモリに干渉しないようにする役割を担っています。 これは メモリ保護 あるプログラムがクラッシュしてもシステム全体がダウンすることがないのは、このおかげです。 そのため、プログラムがより多くのメモリを必要とする場合、単にメモリを取得するのではなく、次のようなシステムコールを使ってカーネルにメモリを要求します。 mmap() または sbrk() . カーネルは、ページテーブルを変更することで、各プロセスにRAMを与えます。

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

動作しない仕組み

256MiBを割り当てるとどうなるかというと、次のようになります。 ない が動作します。

  1. あなたのプロセスコール calloc() で、256MiBを要求しています。

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

  3. カーネルは256MiBの未使用RAMを見つけ、ページテーブルを修正することであなたのプロセスに与えます。

  4. 標準ライブラリは、RAMをゼロにするために memset() から戻り calloc() .

  5. あなたのプロセスは最終的に終了し、カーネルはRAMを再要求して、他のプロセスが使用できるようにします。

実際の動作

上記のプロセスでうまくいくはずなのですが、この方法ではうまくいきません。 大きな違いは3つあります。

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

  • 世の中には、メモリを確保してもすぐには使わないプログラムがたくさんあります。 メモリが確保されても使われないこともある。 カーネルはこのことを知っていて、怠慢なのです。 新しいメモリを割り当てるとき、カーネルはページテーブルにまったく触れず、プロセスにいかなるRAMも与えません。 その代わり、カーネルはプロセス内のあるアドレス空間を見つけ、そこに入るはずのものを記録し、プログラムが実際に使うことがあればそこにRAMを置くと約束するのです。 プログラムがこれらのアドレスから読み書きをしようとすると、プロセッサは ページフォルト カーネルがそのアドレスにRAMを割り当てて、プログラムを再開します。 メモリを使用しない場合、ページフォルトは発生せず、プログラムが実際に RAM を取得することはありません。

  • プロセスによっては、メモリを確保した後、そのまま読み出すものもあります。 つまり、異なるプロセス間のメモリ上の多くのページが、"0 "から "0 "を返したもので埋め尽くされている可能性があります。 mmap() . これらのページはすべて同じであるため、カーネルはこれらの仮想アドレスがゼロで満たされたメモリの単一の共有4KiBページを指すようにします。 そのメモリに書き込もうとすると、プロセッサは別のページフォルトを引き起こし、カーネルは他のプログラムと共有されていないゼロの新鮮なページを提供するために介入するのです。

最終的な処理は、もっとこんな感じです。

  1. あなたのプロセスは calloc() で、256MiBを要求しています。

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

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

  4. 標準ライブラリは mmap() は常にゼロで埋め尽くされる(または になります。 そのため、メモリに触れることはなく、ページフォルトも発生せず、RAMがあなたのプロセスに与えられることはありません。

  5. あなたのプロセスは最終的に終了し、カーネルはRAMを取り戻す必要がありません。なぜなら、RAMはそもそも割り当てられていなかったからです。

を使用する場合 memset() でページをゼロにします。 memset() はページフォルトを起こし、RAM を確保し、そしてすでに 0 で満たされているにもかかわらず、それを 0 にします。 これは膨大な量の余分な作業であり、なぜ 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() ).

パーティーの仕掛け

10回ループさせる代わりに、以下の時間までメモリを確保するプログラムを書いてください。 malloc() または calloc() はNULLを返す。

を追加した場合はどうなるのでしょうか? memset() ?