Mengapa memcpy () dan memmove () lebih cepat daripada peningkatan penunjuk?


92

Saya menyalin N byte dari pSrcke pDest. Ini dapat dilakukan dalam satu putaran:

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

Mengapa ini lebih lambat dari memcpyatau memmove? Trik apa yang mereka gunakan untuk mempercepatnya?


2
Loop Anda hanya menyalin satu lokasi. Saya pikir Anda entah bagaimana bermaksud untuk meningkatkan petunjuk.
Mysticial

13
Atau, Anda bisa memperbaikinya untuk mereka, seperti yang saya lakukan. Dan, BTW, tidak ada programmer C sejati yang pernah menghitung dari 1hingga N, selalu dari 0hingga N-1:-)
paxdiablo

6
@paxdiablo: Jika Anda mengulang array, tentu. Tetapi ada banyak kasus di mana perulangan dari 1 ke N baik-baik saja. Bergantung pada apa yang Anda lakukan dengan data - jika Anda menampilkan daftar bernomor mulai dari 1, misalnya, kepada pengguna, maka mulai dari 1 mungkin lebih masuk akal. Bagaimanapun, ini mengabaikan masalah yang lebih besar yang digunakan intsebagai penghitung ketika tipe unsigned like size_tharus digunakan sebagai gantinya.
Billy ONeal

2
@paxdiablo Anda juga dapat menghitung dari N ke 1. Pada beberapa prosesor yang akan menghilangkan satu instruksi pembanding karena decrement akan menyetel bit yang sesuai untuk instruksi cabang ketika mencapai nol.
onemasse

6
Saya pikir premis pertanyaan itu salah. Kompiler modern akan mengubahnya menjadi memcpyatau memmove(tergantung pada apakah mereka dapat mengetahui apakah pointernya mungkin alias).
David Schwartz

Jawaban:


120

Karena memcpy menggunakan penunjuk kata daripada pengarah byte, implementasi memcpy juga sering ditulis dengan instruksi SIMD yang memungkinkan untuk mengacak 128 bit pada satu waktu.

Instruksi SIMD adalah instruksi perakitan yang dapat melakukan operasi yang sama pada setiap elemen dalam vektor hingga panjang 16 byte. Itu termasuk memuat dan menyimpan instruksi.


15
Saat Anda mengaktifkan GCC -O3, itu akan menggunakan SIMD untuk loop, setidaknya jika ia tahu pDestdan pSrctidak alias.
Dietrich Epp

Saat ini saya sedang mengerjakan Xeon Phi dengan 64 byte (512 bit) SIMD, jadi hal "hingga 16 byte" ini membuat saya tersenyum. Selain itu, Anda harus menentukan CPU yang Anda targetkan agar SIMD dapat diaktifkan, misalnya dengan -march = native.
yakoudbz

Mungkin saya harus merevisi jawaban saya. :)
onemasse

Ini sangat ketinggalan jaman bahkan pada saat posting. Vektor AVX pada x86 (dikirim pada tahun 2011) berukuran 32 byte, dan AVX-512 berukuran 64 byte. Ada beberapa arsitektur dengan vektor 1024-bit atau 2048-bit, atau bahkan lebar vektor variabel seperti ARM SVE
phuclv

@phuclv sementara instruksinya mungkin telah tersedia, apakah Anda memiliki bukti bahwa memcpy menggunakannya? Biasanya diperlukan beberapa saat untuk perpustakaan untuk mengejar ketinggalan, dan yang terbaru dapat saya temukan menggunakan SSSE3 dan jauh lebih baru dari 2011.
Pete Kirkham

81

Rutinitas penyalinan memori bisa jauh lebih rumit dan lebih cepat daripada penyalinan memori sederhana melalui petunjuk seperti:

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++;
}

Perbaikan

Perbaikan pertama yang dapat dilakukan seseorang adalah menyelaraskan salah satu petunjuk pada batas kata (menurut kata yang saya maksud adalah ukuran integer asli, biasanya 32 bit / 4 byte, tetapi bisa 64 bit / 8 byte pada arsitektur yang lebih baru) dan menggunakan gerakan ukuran kata / salin instruksi. Ini membutuhkan penggunaan salinan byte ke byte sampai pointer sejajar.

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--;
    }
  }
}

Arsitektur yang berbeda akan bekerja secara berbeda berdasarkan apakah sumber atau penunjuk tujuan disejajarkan dengan tepat. Misalnya pada prosesor XScale saya mendapatkan kinerja yang lebih baik dengan menyelaraskan penunjuk tujuan daripada penunjuk sumber.

Untuk lebih meningkatkan kinerja, beberapa loop unrolling dapat dilakukan, sehingga lebih banyak register prosesor yang dimuat dengan data dan itu berarti instruksi muat / penyimpanan dapat disisipkan dan latensinya disembunyikan oleh instruksi tambahan (seperti penghitungan loop, dll). Manfaat yang dibawa ini sedikit berbeda oleh prosesor, karena latensi instruksi muat / penyimpanan bisa sangat berbeda.

Pada tahap ini kode akhirnya ditulis dalam Assembly daripada C (atau C ++) karena Anda perlu menempatkan instruksi pemuatan dan penyimpanan secara manual untuk mendapatkan manfaat maksimal dari penyembunyian latensi dan throughput.

Umumnya, seluruh baris data cache harus disalin dalam satu iterasi dari loop yang tidak digulung.

Yang membawa saya ke peningkatan berikutnya, menambahkan pengambilan awal. Ini adalah instruksi khusus yang memberi tahu sistem cache prosesor untuk memuat bagian tertentu dari memori ke dalam cache-nya. Karena ada penundaan antara mengeluarkan instruksi dan mengisi baris cache, instruksi perlu ditempatkan sedemikian rupa sehingga data tersedia ketika akan disalin, dan tidak cepat / lambat.

Ini berarti meletakkan instruksi prefetch di awal fungsi serta di dalam loop salinan utama. Dengan instruksi prefetch di tengah-tengah loop salinan mengambil data yang akan disalin dalam beberapa waktu iterasi.

Saya tidak dapat mengingatnya, tetapi mungkin juga bermanfaat untuk mengambil lebih dulu alamat tujuan serta alamat sumber.

Faktor

Faktor utama yang mempengaruhi seberapa cepat memori dapat disalin adalah:

  • Latensi antara prosesor, cache, dan memori utama.
  • Ukuran dan struktur baris cache prosesor.
  • Instruksi pemindahan / salin memori prosesor (latensi, throughput, ukuran register, dll).

Jadi, jika Anda ingin menulis rutinitas mengatasi memori yang efisien dan cepat, Anda harus mengetahui cukup banyak tentang prosesor dan arsitektur yang Anda tulis. Cukuplah untuk mengatakan, kecuali Anda menulis pada beberapa platform tertanam, akan jauh lebih mudah untuk hanya menggunakan rutinitas penyalinan memori bawaan.


CPU modern akan mendeteksi pola akses memori linier dan mulai mengambil sendiri. Saya berharap instruksi prefetch tidak akan membuat banyak perbedaan karena itu.
maksimal

@maxy Pada beberapa arsitektur yang telah saya implementasikan, rutinitas penyalinan memori menambahkan prefetch telah membantu secara terukur. Meskipun mungkin benar bahwa chip Intel / AMD generasi saat ini melakukan prefetch cukup jauh ke depan, ada banyak chip yang lebih tua dan arsitektur lain yang tidak.
Daemin

adakah yang bisa menjelaskan "(b_src & 0x3)! = 0"? Saya tidak bisa memahaminya, dan juga - itu tidak akan dikompilasi (melempar kesalahan: operator tidak valid ke biner &: unsigned char dan int);
David Refaeli

"(b_src & 0x3)! = 0" memeriksa apakah 2 bit terendah bukan 0. Jadi jika penunjuk sumber disejajarkan dengan kelipatan 4 byte atau tidak. Kesalahan kompilasi Anda terjadi karena memperlakukan 0x3 sebagai byte bukan masuk, Anda dapat memperbaikinya dengan menggunakan 0x00000003 atau 0x3i (menurut saya).
Daemin

b_src & 0x3tidak akan dikompilasi karena Anda tidak diizinkan melakukan aritmatika bitwise pada jenis penunjuk. Anda harus mentransmisikannya (u)intptr_tterlebih dahulu
phuclv

18

memcpydapat menyalin lebih dari satu byte sekaligus tergantung pada arsitektur komputer. Kebanyakan komputer modern dapat bekerja dengan 32 bit atau lebih dalam satu instruksi prosesor.

Dari satu contoh implementasi :

    00026 * Untuk penyalinan cepat, optimalkan kasus umum di mana kedua penunjuk
    00027 * dan panjangnya disejajarkan dengan kata, dan salin kata-pada-waktu sebagai gantinya
    00028 * byte-at-a-time. Jika tidak, salin per byte.

8
Pada 386 (sebagai contoh), yang tidak memiliki cache on-board, hal ini membuat perbedaan besar. Pada kebanyakan prosesor modern, pembacaan dan penulisan akan terjadi satu baris cache pada satu waktu, dan bus ke memori biasanya akan menjadi penghambat, jadi harapkan peningkatan beberapa persen, bukan mendekati empat kali lipat.
Jerry Coffin

2
Saya pikir Anda harus sedikit lebih eksplisit saat mengatakan "dari sumbernya". Tentu, itu adalah "sumber" pada beberapa arsitektur, tetapi jelas bukan pada, katakanlah, mesin BSD atau Windows. (Dan sih, bahkan di antara sistem GNU sering kali ada banyak perbedaan dalam fungsi ini)
Billy ONeal

@Billy ONeal: +1 benar sekali ... ada lebih dari satu cara untuk menguliti kucing. Itu hanya satu contoh. Tetap! Terima kasih atas komentar konstruktifnya.
Mark Byers

7

Anda dapat mengimplementasikan memcpy()menggunakan salah satu teknik berikut, beberapa bergantung pada arsitektur Anda untuk peningkatan performa, dan semuanya akan jauh lebih cepat daripada kode Anda:

  1. Gunakan unit yang lebih besar, seperti kata 32-bit, bukan byte. Anda juga dapat (atau mungkin harus) berurusan dengan penyelarasan di sini juga. Anda tidak dapat membaca / menulis kata 32-bit ke lokasi memori yang aneh misalnya di beberapa platform, dan di platform lain Anda membayar penalti performa yang sangat besar. Untuk mengatasinya, alamatnya harus berupa unit yang dapat dibagi 4. Anda dapat menggunakan hingga 64-bit untuk 64-bit CPU, atau bahkan lebih tinggi menggunakan instruksi SIMD (Instruksi tunggal, banyak data) ( MMX , SSE , dll.)

  2. Anda dapat menggunakan instruksi CPU khusus yang kompilator Anda mungkin tidak dapat mengoptimalkan dari C. Misalnya, pada 80386, Anda dapat menggunakan instruksi awalan "rep" + instruksi "movsb" untuk memindahkan N byte yang ditentukan dengan menempatkan N dalam hitungan daftar. Kompiler yang baik hanya akan melakukan ini untuk Anda, tetapi Anda mungkin berada pada platform yang tidak memiliki kompiler yang baik. Perhatikan, contoh itu cenderung menjadi demonstrasi kecepatan yang buruk, tetapi dikombinasikan dengan penyelarasan + instruksi unit yang lebih besar, ini bisa lebih cepat daripada kebanyakan hal lain pada CPU tertentu.

  3. Loop unrolling - branch bisa sangat mahal pada beberapa CPU, jadi membuka loop dapat menurunkan jumlah cabang. Ini juga merupakan teknik yang baik untuk menggabungkan instruksi SIMD dan unit berukuran sangat besar.

Misalnya, http://www.agner.org/optimize/#asmlib memiliki memcpypenerapan yang paling berhasil (dengan jumlah yang sangat kecil). Jika Anda membaca kode sumbernya, kode tersebut akan penuh dengan banyak kode perakitan sebaris yang menarik ketiga teknik di atas, memilih teknik mana dari teknik tersebut berdasarkan CPU yang Anda gunakan.

Perhatikan, ada juga pengoptimalan serupa yang dapat dilakukan untuk menemukan byte dalam buffer juga. strchr()dan teman-teman akan sering lebih cepat dari yang setara dengan lemparan tangan Anda. Ini terutama berlaku untuk .NET dan Java . Misalnya, dalam .NET, built-in String.IndexOf()jauh lebih cepat daripada pencarian string Boyer-Moore , karena menggunakan teknik pengoptimalan di atas.


1
Agner Fog yang sama yang Anda tautkan juga berteori bahwa loop unrolling kontraproduktif pada CPU modern .

Sebagian besar CPU saat ini memiliki prediksi cabang yang baik, yang seharusnya meniadakan manfaat loop unrolling dalam kasus-kasus tertentu. Kompiler pengoptimalan yang baik terkadang masih bisa menggunakannya.
thomasrutter

5

Jawaban singkat:

  • isi cache
  • wordsize mentransfer alih-alih yang byte jika memungkinkan
  • Keajaiban SIMD

4

Saya tidak tahu apakah itu benar-benar digunakan dalam implementasi dunia nyata memcpy, tapi saya pikir Perangkat Duff layak disebutkan di sini.

Dari Wikipedia :

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);
        }
}

Perhatikan bahwa hal di atas bukan memcpykarena sengaja tidak menaikkan topenunjuk. Ini mengimplementasikan operasi yang sedikit berbeda: penulisan ke dalam register yang dipetakan memori. Lihat artikel Wikipedia untuk detailnya.


Perangkat Duff, atau hanya mekanisme lompatan awal, adalah penggunaan yang baik untuk menyalin 1..3 (atau 1..7) byte pertama sehingga penunjuk sejajar dengan batas yang lebih bagus di mana instruksi pemindahan memori yang lebih besar dapat digunakan.
Daemin

@ MarkByers: Kode menggambarkan operasi yang sedikit berbeda ( *tomengacu pada register yang dipetakan memori dan sengaja tidak bertambah - lihat artikel tertaut ke). Seperti yang saya pikir sudah saya jelaskan, jawaban saya tidak berusaha memberikan efisiensi memcpy, itu hanya menyebutkan teknik yang agak aneh.
NPE

@Daemin Setuju, seperti yang Anda katakan Anda bisa melewati do {} while () dan sakelar akan diterjemahkan ke tabel lompat oleh kompilator. Sangat berguna saat Anda ingin mengurus data yang tersisa. Peringatan harus disebutkan tentang perangkat Duff, tampaknya pada arsitektur yang lebih baru (x86 yang lebih baru), prediksi cabang sangat efisien sehingga perangkat Duff sebenarnya lebih lambat daripada loop sederhana.
onemasse

1
Oh tidak .. bukan perangkat Duff. Tolong jangan gunakan perangkat Duff. Silahkan. Gunakan PGO dan biarkan saya compiler melakukan loop unrolling untuk Anda di tempat yang masuk akal.
Billy ONeal

Tidak, perangkat Duff pasti tidak digunakan dalam implementasi modern apa pun.
gnasher729

3

Seperti orang lain mengatakan salinan memcpy lebih besar dari potongan 1-byte. Menyalin dalam potongan berukuran kata jauh lebih cepat. Namun, sebagian besar implementasi mengambil langkah lebih jauh dan menjalankan beberapa instruksi MOV (kata) sebelum melakukan perulangan. Keuntungan dari menyalin, katakanlah, 8 blok kata per loop adalah bahwa loop itu sendiri mahal. Teknik ini mengurangi jumlah cabang bersyarat dengan faktor 8, mengoptimalkan salinan untuk balok raksasa.


1
Saya rasa ini tidak benar. Anda dapat membatalkan gulungan, tetapi Anda tidak dapat menyalin dalam satu instruksi lebih banyak data daripada yang dapat dialamatkan pada satu waktu pada arsitektur target. Ditambah, ada overhead untuk membuka gulungannya juga ...
Billy ONeal

@ Billy ONeal: Menurutku bukan itu yang dimaksud VoidStar. Dengan memiliki beberapa instruksi gerakan berturut-turut, overhead penghitungan jumlah unit berkurang.
wallyk

@ Billy ONeal: Anda melewatkan intinya. 1-kata pada satu waktu adalah seperti MOV, JMP, MOV, JMP, dll. Dimana Anda dapat melakukan MOV MOV MOV MOV JMP. Saya telah menulis mempcy sebelumnya dan saya telah membandingkan banyak cara untuk melakukannya;)
VoidStar

@wallyk: Mungkin. Tapi dia mengatakan "menyalin potongan yang lebih besar" - yang sebenarnya tidak mungkin. Jika maksudnya loop unrolling, maka dia harus mengatakan "sebagian besar implementasi mengambil langkah lebih jauh dan membatalkan loop." Jawaban seperti yang tertulis paling banter menyesatkan, paling buruk salah.
Billy ONeal

@VoidStar: Setuju --- sekarang lebih baik. +1.
Billy ONeal

2

Jawaban yang besar, tetapi jika Anda masih ingin menerapkan cepat suatu memcpydiri Anda, ada sebuah posting blog menarik tentang memcpy cepat, memcpy Cepat di C .

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;
}

Bahkan, bisa lebih baik lagi dengan mengoptimalkan akses memori.


1

Karena seperti banyak rutinitas perpustakaan, ini telah dioptimalkan untuk arsitektur yang Anda jalankan. Yang lain telah memposting berbagai teknik yang dapat digunakan.

Diberikan pilihan, gunakan rutinitas perpustakaan daripada roll Anda sendiri. Ini adalah variasi KERING yang saya sebut DRO (Don't Repeat Others). Selain itu, rutinitas perpustakaan cenderung tidak salah dibandingkan penerapan Anda sendiri.

Saya telah melihat pemeriksa akses memori mengeluh tentang pembacaan di luar batas pada memori atau buffer string yang bukan merupakan kelipatan dari ukuran kata. Ini adalah hasil dari pengoptimalan yang digunakan.


0

Anda dapat melihat implementasi MacOS dari memset, memcpy dan memmove.

Saat boot, OS menentukan prosesor mana yang menjalankannya. Ini telah membangun kode yang dioptimalkan secara khusus untuk setiap prosesor yang didukung, dan pada saat boot menyimpan instruksi jmp ke kode yang tepat di lokasi hanya baca / tetap.

Implementasi C memset, memcpy dan memmove hanyalah lompatan ke lokasi tetap itu.

Implementasi menggunakan kode yang berbeda tergantung pada penyelarasan sumber dan tujuan untuk memcpy dan memmove. Mereka jelas menggunakan semua kemampuan vektor yang tersedia. Mereka juga menggunakan varian non-caching saat Anda menyalin data dalam jumlah besar, dan memiliki instruksi untuk meminimalkan menunggu tabel halaman. Ini bukan hanya kode assembler, ini adalah kode assembler yang ditulis oleh seseorang dengan pengetahuan yang sangat baik tentang setiap arsitektur prosesor.

Intel juga menambahkan instruksi assembler yang dapat membuat operasi string lebih cepat. Misalnya dengan instruksi untuk mendukung strstr yang melakukan perbandingan 256 byte dalam satu siklus.


Memset / memcpy / memmove versi open source Apple hanyalah versi generik yang akan jauh lebih lambat daripada versi aslinya menggunakan SIMD
phuclv
Dengan menggunakan situs kami, Anda mengakui telah membaca dan memahami Kebijakan Cookie dan Kebijakan Privasi kami.
Licensed under cc by-sa 3.0 with attribution required.