memcpy()とmemmove()がポインターの増分よりも速いのはなぜですか?


92

からpSrcにNバイトをコピーしていpDestます。これは単一のループで行うことができます:

for (int i = 0; i < N; i++)
    *pDest++ = *pSrc++

なぜこれより遅いのですmemcpymemmove?彼らはそれをスピードアップするためにどのようなトリックを使用していますか?


2
ループは1つの場所のみをコピーします。どういうわけか、ポインタをインクリメントするつもりだったと思います。
Mysticial 2011年

13
または、私がしたように、彼らのためにそれを修正することもできます。そして、ところで、真のCプログラマかつてからのカウント1N、それはだ常にから0N-1:-)
paxdiablo

6
@paxdiablo:配列をループしている場合は、確認してください。しかし、1からNへのループで十分な場合がたくさんあります。データをどのように処理しているかによって異なります。たとえば、ユーザーに対して1から始まる番号付きリストを表示している場合は、1から開始する方が理にかなっています。いずれの場合も、代わりにintunsigned型をsize_t使用する必要がある場合に、カウンターとして使用しているより大きな問題を無視します。
Billy ONeal、2011年

2
@paxdiablo Nから1までカウントすることもできます。一部のプロセッサでは、ゼロに達するとデクリメントによって分岐命令に適切なビットが設定されるため、1つの比較命令が削除されます。
onemasse '15年

6
質問の前提は間違っていると思います。最新のコンパイラーはこれをmemcpyまたはに変換しますmemmove(ポインターがエイリアスになるかどうかを判別できるかどうかによって異なります)。
David Schwartz

回答:


120

memcpyはバイトポインターの代わりにワードポインターを使用するため、memcpyの実装もしばしばSIMD命令で記述され、一度に128ビットをシャッフルすることができます。

SIMD命令は、長さが最大16バイトのベクトルの各要素に対して同じ操作を実行できるアセンブリ命令です。これには、ロードとストアの命令が含まれます。


15
GCCをにすると-O3、少なくともエイリアスがわかっpDestpSrcいてエイリアスがない場合は、ループにSIMDが使用されます。
ディートリッヒエップ2011年

現在64バイト(512ビット)のSIMDを備えたXeon Phiに取り組んでいるので、この「最大16バイト」の要素は私を笑顔にします。さらに、SIMDを有効にするために対象とするCPUを指定する必要があります(-march = nativeなど)。
yakoudbz

多分私は私の答えを修正する必要があります。:)
onemasse 2016年

これは投稿時でも非常に古くなっています。x86(2011年に出荷)のAVXベクトルは32バイト長で、AVX-512は64バイト長です。1024ビットまたは2048ビットのベクター、またはARM SVEのような可変ベクター幅のアーキテクチャもあります
phuclv

@phuclv命令が利用可能だったかもしれませんが、memcpyがそれらを使用している証拠はありますか?ライブラリが追いつくまでには通常しばらく時間がかかります。また、私が見つけた最新のものはSSSE3を使用しており、2011
Pete Kirkham

81

メモリコピールーチンは、次のようなポインタを介した単純なメモリコピーよりもはるかに複雑で高速です。

void simple_memory_copy(void* dst, void* src, unsigned int bytes)
{
  unsigned char* b_dst = (unsigned char*)dst;
  unsigned char* b_src = (unsigned char*)src;
  for (int i = 0; i < bytes; ++i)
    *b_dst++ = *b_src++;
}

改善点

最初にできる改善は、ポインターの1つをワード境界に揃えることです(つまり、ネイティブの整数サイズ、通常32ビット/ 4バイトですが、新しいアーキテクチャでは64ビット/ 8バイトにすることができます)。ワードサイズの移動を使用します。 /コピー手順。これには、ポインタが揃うまでバイトからバイトへのコピーを使用する必要があります。

void aligned_memory_copy(void* dst, void* src, unsigned int bytes)
{
  unsigned char* b_dst = (unsigned char*)dst;
  unsigned char* b_src = (unsigned char*)src;

  // Copy bytes to align source pointer
  while ((b_src & 0x3) != 0)
  {
    *b_dst++ = *b_src++;
    bytes--;
  }

  unsigned int* w_dst = (unsigned int*)b_dst;
  unsigned int* w_src = (unsigned int*)b_src;
  while (bytes >= 4)
  {
    *w_dst++ = *w_src++;
    bytes -= 4;
  }

  // Copy trailing bytes
  if (bytes > 0)
  {
    b_dst = (unsigned char*)w_dst;
    b_src = (unsigned char*)w_src;
    while (bytes > 0)
    {
      *b_dst++ = *b_src++;
      bytes--;
    }
  }
}

アーキテクチャが異なると、ソースポインターまたは宛先ポインターが適切に配置されているかどうかに基づいて、動作が異なります。たとえば、XScaleプロセッサでは、ソースポインターではなく宛先ポインターを調整することで、パフォーマンスが向上しました。

パフォーマンスをさらに向上させるために、いくつかのループのアンロールを実行できます。これにより、プロセッサーのより多くのレジスターにデータがロードされます。つまり、ロード/ストア命令をインターリーブし、追加の命令(ループカウントなど)によってレイテンシを隠すことができます。ロード/ストア命令のレイテンシはまったく異なる可能性があるため、これによってもたらされる利点はプロセッサによってかなり異なります。

この段階では、コードをC(またはC ++)ではなくアセンブリで記述します。これは、ロードおよびストア命令を手動で配置して、レイテンシの隠蔽とスループットを最大限に引き出す必要があるためです。

通常、データのキャッシュライン全体を、展開されたループの1回の反復でコピーする必要があります。

これにより、プリフェッチを追加して、次の改善が可能になります。これらは、プロセッサのキャッシュシステムにメモリの特定の部分をキャッシュにロードするように指示する特別な命令です。命令を発行してからキャッシュラインが満たされるまでに遅延があるため、データをコピーするときにデータを利用できるように、命令を配置する必要があります。

これは、プリフェッチ命令を関数の開始時とメインコピーループ内に置くことを意味します。コピーループの途中にあるプリフェッチ命令を使用して、数回の反復でコピーされるデータをフェッチします。

思い出せませんが、送信元アドレスだけでなく宛先アドレスもプリフェッチするとよいでしょう。

要因

メモリのコピー速度に影響する主な要因は次のとおりです。

  • プロセッサ、そのキャッシュ、およびメインメモリ間のレイテンシ。
  • プロセッサのキャッシュラインのサイズと構造。
  • プロセッサのメモリ移動/コピー命令(レイテンシ、スループット、レジスタサイズなど)。

したがって、効率的で高速なメモリ処理ルーチンを作成する場合は、作成するプロセッサとアーキテクチャについてかなりの知識が必要になります。言うまでもありませんが、組み込みプラットフォームで書いているのでなければ、組み込みのメモリコピールーチンを使用する方がはるかに簡単です。


最近のCPUは、線形メモリアクセスパターンを検出し、独自にプリフェッチを開始します。そのため、プリフェッチ命令はそれほど大きな違いをもたらさないと思います。
最大の

@maxy私がメモリコピールーチンを実装したいくつかのアーキテクチャでは、プリフェッチを追加することで、ある程度の効果が得られました。現世代のIntel / AMDチップが先読みを十分に行っていることは事実かもしれませんが、そうでない古いチップや他のアーキテクチャはたくさんあります。
Daemin、2011年

誰かが「(b_src&0x3)!= 0 "を説明できますか?私はそれを理解できません、そしてまた-それはコンパイルされません(エラーをスローします:バイナリ&への無効な演算子&:unsigned charおよびint);
David Refaeli 2016

"(b_src&0x3)!= 0"は、最下位の2ビットが0でないかどうかをチェックしています。したがって、ソースポインタが4バイトの倍数に整列しているかどうか。コンパイルエラーは、0x3をインではなくバイトとして処理しているために発生します。0x00000003または0x3iを使用すると、エラーを修正できます(私はそう思います)。
Daemin

b_src & 0x3ポインタ型に対してビット演算を行うことができないため、コンパイルされません。(u)intptr_t最初にキャストする必要があります
phuclv

18

memcpyコンピュータのアーキテクチャによっては、一度に複数のバイトをコピーできます。最近のほとんどのコンピュータは、単一のプロセッサ命令で32ビット以上で動作します。

1つの実装例から:

    00026 *迅速なコピーのために、両方のポインターが存在する一般的なケースを最適化します
    00027 *と長さはワード境界で整列され、代わりに一度に一度にコピーされます
    00028 *一度に1バイト。それ以外の場合は、バイト単位でコピーします。

8
オンボードキャッシュのない386(1つの例)では、これは大きな違いをもたらしました。最近のほとんどのプロセッサでは、読み取りと書き込みは一度に1つのキャッシュラインで行われ、メモリへのバスは通常ボトルネックになるため、4倍に近い場所ではなく、数パーセントの改善が見込まれます。
ジェリーコフィン

2
「ソースから」と言うときは、もう少し明確にすべきだと思います。もちろん、これは一部のアーキテクチャでは「ソース」ですが、BSDマシンやWindowsマシンなどにはありません。(そして、地獄、GNUシステム間でも、この関数には多くの違いがあることが多い)
Billy ONeal

@Billy ONeal:+1間違いなく...猫の皮をむく方法は複数あります。それはほんの一例です。修繕!建設的なコメントをありがとう。
Mark Byers、2011年

7

memcpy()以下の手法のいずれかを使用して実装できます。一部はパフォーマンスの向上をアーキテクチャに依存しますが、それらはすべてコードよりもはるかに高速です。

  1. バイトの代わりに32ビットワードなどのより大きな単位を使用します。また、ここでアラインメントを処理することもできます(または必要になる場合もあります)。たとえば、一部のプラットフォームでは、32ビットワードを奇数のメモリ位置に読み書きできません。他のプラットフォームでは、パフォーマンスが大幅に低下します。これを修正するには、アドレスを4で割り切れる単位にする必要があります。これは、64ビットCPUの場合は最大64ビット、SIMD(単一命令、複数データ)命令(MMXSSEなど)を使用してそれ以上にすることができます。

  2. コンパイラーがCから最適化できない特殊なCPU命令を使用できます。たとえば、80386では、「rep」プレフィックス命令+「movsb」命令を使用して、カウントにNを置くことによって指示されたNバイトを移動できます。登録。優れたコンパイラはこれを行うだけですが、優れたコンパイラが不足しているプラ​​ットフォームを使用している可能性があります。この例は速度の悪いデモンストレーションになる傾向がありますが、アラインメント+より大きなユニット命令と組み合わせると、特定のCPUの他のほとんどすべてのものよりも高速になる可能性があることに注意してください。

  3. ループのアンロール -一部のCPUではブランチが非常に高価になる可能性があるため、ループをアンロールするとブランチの数を減らすことができます。これは、SIMD命令や非常に大きなサイズのユニットと組み合わせるのにも適した手法です。

たとえば、http://www.agner.org/optimize/#asmlibにmemcpy、非常に少ない(非常に少ない)実装が実装されています。ソースコードを読むと、実行中のCPUに基づいて上記の3つの手法をすべて引き出し、それらの手法をどれにするかを選択する、大量のインラインアセンブリコードでいっぱいになります。

バッファ内のバイトを見つけるために行うことができる同様の最適化があることに注意してください。strchr()そして、友達はあなたの手で同等のものよりもしばしば速くなります。これは特に.NETJavaに当てはまります。たとえば、.NET では、上記の最適化手法を使用しているため、組み込みString.IndexOf()Boyer–Moore文字列検索よりもはるかに高速です。


1
リンク先と同じAgner Fogも、ループの展開が最近のCPUでは逆効果であることを理論化しています

最近のほとんどのCPUには、適切な分岐予測があります。これにより、通常の場合のループ展開の利点が無効になります。優れた最適化コンパイラは、時々それを使用できます。
thomasrutter 2017

5

短い答え:

  • キャッシュフィル
  • 可能であれば、バイト転送ではなくワードサイズ転送
  • SIMDマジック

4

実際のの実装で実際に使用されているかどうかはわかりませんがmemcpyダフのデバイスはここで言及する価値があると思います。

ウィキペディアから:

send(to, from, count)
register short *to, *from;
register count;
{
        register n = (count + 7) / 8;
        switch(count % 8) {
        case 0:      do {     *to = *from++;
        case 7:              *to = *from++;
        case 6:              *to = *from++;
        case 5:              *to = *from++;
        case 4:              *to = *from++;
        case 3:              *to = *from++;
        case 2:              *to = *from++;
        case 1:              *to = *from++;
                } while(--n > 0);
        }
}

上記はmemcpy意図的にtoポインタをインクリメントしないため、上記はaではないことに注意してください。それは少し異なる操作を実装します:メモリマップされたレジスタへの書き込み。詳細については、Wikipediaの記事を参照してください。


Duffのデバイス、または単に最初のジャンプメカニズムは、最初の1..3(または1..7)バイトをコピーして、ポインターがより大きなメモリ移動命令を使用できるより適切な境界に揃えられるようにするのに適しています。
Daemin

@MarkByers:コードはわずかに異なる操作を示しています(*toメモリマップレジスタを参照し、意図的にインクリメントされていません-リンク先の記事を参照してください)。私が明確にしたと思ったように、私の答えは効率的なを提供しようとするものではなくmemcpy、単に奇妙なテクニックに言及しているだけです。
NPE、2011年

@Daeminは同意しました。あなたが言ったように、do {} while()をスキップすることができ、スイッチはコンパイラによってジャンプテーブルに変換されます。残りのデータを処理する場合に非常に役立ちます。明らかに新しいアーキテクチャ(新しいx86)では、Duffのデバイスについて警告を述べる必要があります。分岐予測は非常に効率的であるため、Duffのデバイスは実際には単純なループよりも低速です。
onemasse '15年

1
ダフのデバイスじゃない。ダフのデバイスを使用しないでください。お願いします。PGOを使用して、意味のある場所でコンパイラーにループのアンロールを実行させます。
Billy ONeal、2011年

いいえ、ダフのデバイスは、間違いなく最新の実装では使用されていません。
gnasher729

3

他の人のように、memcpyは1バイトのチャンクよりも大きいコピーをします。ワードサイズのチャンクでのコピーははるかに高速です。ただし、ほとんどの実装ではさらに一歩進んで、ループする前にいくつかのMOV(ワード)命令を実行します。たとえば、ループごとに8ワードブロックをコピーする利点は、ループ自体にコストがかかることです。この手法は、条件付きブランチの数を8分の1に減らし、巨大ブロックのコピーを最適化します。


1
これは本当ではないと思います。ループをアンロールすることはできますが、ターゲットアーキテクチャで一度にアドレス指定可能なデータよりも多くのデータを単一の命令でコピーすることはできません。さらに、ループをアンロールするオーバーヘッドもあります...
Billy ONeal

@Billy ONeal:それがVoidStarの意味するところだとは思いません。いくつかの連続した移動命令を持つことにより、ユニット数を数えるオーバーヘッドが減少します。
wallyk '15年

@Billy ONeal:ポイントを逃しています。一度に1ワードは、MOV、JMP、MOV、JMPなどのようなものです。MOVMOV MOV MOV JMPと同じようにできます。私は前にメンピスを書いたことがあり、それを行う多くの方法をベンチマークしました;)
VoidStar

@wallyk:たぶん。しかし、彼は「さらに大きなチャンクをコピーする」と言います-これは実際には不可能です。彼がループのアンロールを意味する場合、「ほとんどの実装はそれをさらに一歩進めてループをアンロールする」と言う必要があります。書かれた答えはせいぜい誤解を招くだけで、最悪の場合は間違っています。
Billy ONeal、2011年

@VoidStar:同意---それは今より良いです。+1。
Billy ONeal、2011年

2

答えは素晴らしいですが、それでも高速をmemcpy自分で実装したい場合は、高速memcpyに関する興味深いブログ投稿、CのFast memcpyがあります。

void *memcpy(void* dest, const void* src, size_t count)
{
    char* dst8 = (char*)dest;
    char* src8 = (char*)src;

    if (count & 1) {
        dst8[0] = src8[0];
        dst8 += 1;
        src8 += 1;
    }

    count /= 2;
    while (count--) {
        dst8[0] = src8[0];
        dst8[1] = src8[1];

        dst8 += 2;
        src8 += 2;
    }
    return dest;
}

それでも、メモリアクセスを最適化することで改善できます。


1

多くのライブラリルーチンと同様に、実行しているアーキテクチャ用に最適化されています。他にも、使用できるさまざまなテクニックを掲載しています。

選択肢があれば、自分でロールするのではなく、ライブラリルーチンを使用してください。これは、私がDRO(Do n't Repeat Others)と呼ぶDRYのバリエーションです。また、ライブラリルーチンは、独自の実装よりも間違いが少ない可能性があります。

メモリアクセスチェッカーが、ワードサイズの倍数ではないメモリまたは文字列バッファの範囲外の読み取りについて不平を言うのを見ました。これは、使用されている最適化の結果です。


0

memset、memcpy、memmoveのMacOS実装を見ることができます。

起動時に、OSは実行しているプロセッサを判別します。サポートされている各プロセッサ用に特別に最適化されたコードが組み込まれており、起動時にjmp命令を読み取り専用の固定位置にある適切なコードに保存します。

C memset、memcpy、およびmemmoveの実装は、その固定された場所へのジャンプにすぎません。

実装は、memcpyとmemmoveのソースと宛先の配置に応じて異なるコードを使用します。彼らは明らかに利用可能なすべてのベクトル機能を使用しています。また、大量のデータをコピーするときに非キャッシュバリアントを使用し、ページテーブルの待機を最小限に抑えるための指示があります。これは単なるアセンブラコードではなく、各プロセッサアーキテクチャに関する非常に優れた知識を持つ誰かが作成したアセンブラコードです。

インテルはまた、文字列操作を高速化できるアセンブラー命令を追加しました。たとえば、1サイクルで256バイトの比較を行うstrstrをサポートする命令を使用します。


Appleのオープンソースバージョンのmemset / memcpy / memmoveは、一般的なバージョンであり、SIMDを使用した実際のバージョンよりもかなり遅くなります
phuclv
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.