Tại sao memcpy () và memmove () nhanh hơn số tăng con trỏ?


92

Tôi đang sao chép N byte từ pSrcsang pDest. Điều này có thể được thực hiện trong một vòng lặp:

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

Tại sao điều này lại chậm hơn memcpyhoặc memmove? Những thủ thuật nào họ sử dụng để tăng tốc độ?


2
Vòng lặp của bạn chỉ sao chép một vị trí. Tôi nghĩ rằng bằng cách nào đó bạn có ý định tăng các con trỏ.
Mysticial

13
Hoặc, bạn có thể sửa nó cho họ, như tôi đã làm. Và, BTW, không có C lập trình viên thực sự bao giờ đếm từ 1đến N, đó là luôn luôn từ 0để N-1:-)
paxdiablo

6
@paxdiablo: Nếu bạn đang lặp lại các mảng, chắc chắn. Nhưng có rất nhiều trường hợp vòng lặp từ 1 đến N là tốt. Phụ thuộc vào những gì bạn đang làm với dữ liệu - ví dụ: nếu bạn đang hiển thị danh sách được đánh số bắt đầu từ 1 cho một người dùng, thì việc bắt đầu từ 1 có thể có ý nghĩa hơn. Trong mọi trường hợp, nó bỏ qua vấn đề lớn hơn đang sử dụng intlàm bộ đếm khi một kiểu không dấu như size_tnên được sử dụng thay thế.
Billy ONeal

2
@paxdiablo Bạn cũng có thể đếm từ N đến 1. Trên một số bộ xử lý sẽ loại bỏ một lệnh so sánh vì sự giảm dần sẽ đặt bit thích hợp cho lệnh rẽ nhánh khi nó bằng không.
onemasse

6
Tôi nghĩ rằng tiền đề của câu hỏi là sai. Các trình biên dịch hiện đại sẽ chuyển đổi điều này thành memcpyhoặc memmove(tùy thuộc vào việc họ có thể biết liệu các con trỏ có thể là bí danh hay không).
David Schwartz

Câu trả lời:


120

Bởi vì memcpy sử dụng con trỏ từ thay vì con trỏ byte, cũng như việc triển khai memcpy thường được viết với các lệnh SIMD để có thể xáo trộn 128 bit cùng một lúc.

Các lệnh SIMD là các lệnh lắp ráp có thể thực hiện cùng một thao tác trên mỗi phần tử trong một vectơ dài tối đa 16 byte. Điều đó bao gồm hướng dẫn tải và lưu trữ.


15
Khi bạn bật GCC lên -O3, nó sẽ sử dụng SIMD cho vòng lặp, ít nhất là nếu nó biết pDestpSrckhông có bí danh.
Dietrich Epp

Tôi hiện đang làm việc trên Xeon Phi với SIMD 64 byte (512 bit), vì vậy nội dung "lên đến 16 byte" này khiến tôi mỉm cười. Ngoài ra, bạn phải chỉ định CPU mà bạn đang nhắm mục tiêu để SIMD được bật, ví dụ: với -march = native.
yakoudbz

Có lẽ tôi nên sửa lại câu trả lời của mình. :)
onemasse

Điều này rất lỗi thời ngay cả tại thời điểm đăng. Các vectơ AVX trên x86 (xuất xưởng năm 2011) dài 32 byte và AVX-512 dài 64 byte. Có một số kiến ​​trúc có vectơ 1024-bit hoặc 2048-bit, hoặc thậm chí độ rộng vectơ có thể thay đổi như ARM SVE
phuclv

@phuclv trong khi các hướng dẫn có thể đã có sẵn sau đó, bạn có bằng chứng nào cho thấy memcpy sử dụng chúng không? Thông thường sẽ mất một lúc để các thư viện bắt kịp và những thư viện mới nhất tôi có thể tìm thấy sử dụng SSSE3 và gần đây hơn nhiều so với năm 2011.
Pete Kirkham

81

Quy trình sao chép bộ nhớ có thể phức tạp hơn và nhanh hơn nhiều so với việc sao chép bộ nhớ đơn giản thông qua các con trỏ như:

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

Cải tiến

Cải tiến đầu tiên mà người ta có thể thực hiện là căn chỉnh một trong các con trỏ trên một ranh giới từ (tôi có nghĩa là kích thước số nguyên gốc, thường là 32 bit / 4 byte, nhưng có thể là 64 bit / 8 byte trên các kiến ​​trúc mới hơn) và sử dụng kích thước từ di chuyển / sao chép hướng dẫn. Điều này yêu cầu sử dụng bản sao byte sang byte cho đến khi con trỏ được căn chỉnh.

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

Các kiến ​​trúc khác nhau sẽ hoạt động khác nhau dựa trên việc nguồn hoặc con trỏ đích có được căn chỉnh thích hợp hay không. Ví dụ: trên bộ xử lý XScale, tôi có hiệu suất tốt hơn bằng cách căn chỉnh con trỏ đích thay vì con trỏ nguồn.

Để cải thiện hơn nữa hiệu suất, một số thao tác giải nén vòng lặp có thể được thực hiện, để nhiều thanh ghi của bộ xử lý được tải dữ liệu hơn và điều đó có nghĩa là các lệnh tải / lưu trữ có thể được xen kẽ và ẩn độ trễ của chúng bằng các lệnh bổ sung (chẳng hạn như đếm vòng lặp, v.v.). Lợi ích mà điều này mang lại thay đổi khá nhiều bởi bộ xử lý, vì độ trễ của lệnh tải / lưu trữ có thể khá khác nhau.

Ở giai đoạn này, mã kết thúc được viết bằng Assembly thay vì C (hoặc C ++) vì bạn cần phải đặt các lệnh tải và lưu trữ theo cách thủ công để có được lợi ích tối đa của việc ẩn độ trễ và thông lượng.

Nói chung, toàn bộ dòng dữ liệu trong bộ nhớ cache nên được sao chép trong một lần lặp lại của vòng lặp chưa được cuộn.

Điều này đưa tôi đến cải tiến tiếp theo, thêm tính năng tìm nạp trước. Đây là những lệnh đặc biệt yêu cầu hệ thống bộ nhớ đệm của bộ xử lý tải các phần cụ thể của bộ nhớ vào bộ nhớ đệm của nó. Vì có sự chậm trễ giữa việc đưa ra lệnh và điền vào dòng bộ đệm, nên các lệnh cần được đặt theo cách sao cho dữ liệu có sẵn ngay khi nó được sao chép và không sớm / muộn.

Điều này có nghĩa là đặt các hướng dẫn tìm nạp trước khi bắt đầu hàm cũng như bên trong vòng lặp sao chép chính. Với hướng dẫn tìm nạp trước ở giữa vòng lặp sao chép, dữ liệu sẽ được sao chép trong nhiều lần lặp lại.

Tôi không thể nhớ, nhưng cũng có thể có lợi khi tìm nạp trước địa chỉ đích cũng như địa chỉ nguồn.

Các nhân tố

Các yếu tố chính ảnh hưởng đến tốc độ sao chép của bộ nhớ là:

  • Độ trễ giữa bộ xử lý, bộ nhớ đệm và bộ nhớ chính.
  • Kích thước và cấu trúc của các dòng bộ nhớ đệm của bộ xử lý.
  • Hướng dẫn sao chép / di chuyển bộ nhớ của bộ xử lý (độ trễ, thông lượng, kích thước thanh ghi, v.v.).

Vì vậy, nếu bạn muốn viết một quy trình xử lý bộ nhớ hiệu quả và nhanh chóng, bạn sẽ cần phải biết khá nhiều về bộ xử lý và kiến ​​trúc mà bạn đang viết. Chỉ cần nói rằng, trừ khi bạn đang viết trên một nền tảng nhúng nào đó sẽ dễ dàng hơn nhiều nếu chỉ sử dụng các quy trình sao chép bộ nhớ được tích hợp sẵn.


Các CPU hiện đại sẽ phát hiện kiểu truy cập bộ nhớ tuyến tính và tự bắt đầu tìm nạp trước. Tôi hy vọng rằng các hướng dẫn tìm nạp trước sẽ không tạo ra nhiều khác biệt vì điều đó.
maxy

@maxy Trên một số kiến ​​trúc mà tôi đã triển khai quy trình sao chép bộ nhớ, việc thêm tìm nạp trước đã giúp ích một cách đáng kể. Mặc dù có thể đúng rằng các chip Intel / AMD thế hệ hiện tại có khả năng tìm nạp trước đủ xa, nhưng có rất nhiều chip cũ hơn và các kiến ​​trúc khác không làm như vậy.
Daemin

bất cứ ai có thể giải thích "(b_src & 0x3)! = 0"? Tôi không thể hiểu nó, và cũng - nó sẽ không biên dịch (ném lỗi: toán tử không hợp lệ thành nhị phân &: unsigned char và int);
David Refaeli

"(b_src & 0x3)! = 0" đang kiểm tra xem 2 bit thấp nhất không phải là 0. Vì vậy, nếu con trỏ nguồn có được căn chỉnh thành bội số 4 byte hay không. Lỗi biên dịch của bạn xảy ra vì nó đang coi 0x3 là một byte không phải là một trong, bạn có thể sửa lỗi đó bằng cách sử dụng 0x00000003 hoặc 0x3i (tôi nghĩ vậy).
Daemin

b_src & 0x3sẽ không biên dịch vì bạn không được phép tính toán từng bit trên các loại con trỏ. Bạn phải truyền nó đến (u)intptr_ttrước
phuclv

18

memcpycó thể sao chép nhiều hơn một byte cùng một lúc tùy thuộc vào kiến ​​trúc của máy tính. Hầu hết các máy tính hiện đại có thể hoạt động với 32 bit hoặc hơn trong một lệnh xử lý đơn lẻ.

Từ một ví dụ triển khai :

    00026 * Để sao chép nhanh chóng, hãy tối ưu hóa trường hợp phổ biến trong đó cả hai con trỏ
    00027 * và độ dài được căn chỉnh theo từ và thay vào đó hãy sao chép từng từ một
    00028 * byte-at-a-time. Nếu không, hãy sao chép theo byte.

8
Trên 386 (ví dụ), không có bộ nhớ cache trên bo mạch, điều này đã tạo ra sự khác biệt rất lớn. Trên hầu hết các bộ vi xử lý hiện đại, việc đọc và ghi sẽ xảy ra một dòng trong bộ nhớ cache tại một thời điểm và bus tới bộ nhớ thường sẽ là nút cổ chai, vì vậy hãy mong đợi sự cải thiện vài phần trăm, không phải bất cứ nơi nào gần gấp bốn lần.
Jerry Coffin

2
Tôi nghĩ bạn nên rõ ràng hơn một chút khi bạn nói "từ nguồn". Chắc chắn, đó là "nguồn" trên một số kiến ​​trúc, nhưng chắc chắn không phải trên máy BSD hoặc Windows. (Và địa ngục, thậm chí giữa các hệ thống GNU thường có rất nhiều khác biệt trong chức năng này)
Billy Oneal

@Billy ONeal: +1 hoàn toàn đúng ... có nhiều cách để lột da mèo. Đó chỉ là một ví dụ. Đã sửa! Cảm ơn vì nhận xét mang tính xây dựng.
Mark Byers

7

Bạn có thể triển khai memcpy()bằng bất kỳ kỹ thuật nào sau đây, một số kỹ thuật phụ thuộc vào kiến ​​trúc của bạn để tăng hiệu suất và tất cả chúng sẽ nhanh hơn nhiều so với mã của bạn:

  1. Sử dụng các đơn vị lớn hơn, chẳng hạn như các từ 32 bit thay vì byte. Bạn cũng có thể (hoặc có thể phải) đối phó với sự liên kết ở đây. Bạn không thể đọc / ghi một từ 32-bit vào một vị trí bộ nhớ kỳ lạ, ví dụ như trên một số nền tảng và trên các nền tảng khác, bạn phải trả một khoản phạt hiệu suất lớn. Để khắc phục điều này, địa chỉ phải là đơn vị chia hết cho 4. Bạn có thể sử dụng tối đa 64 bit cho CPU 64 bit hoặc thậm chí cao hơn bằng cách sử dụng lệnh SIMD ( Lệnh đơn, nhiều dữ liệu) ( MMX , SSE , v.v.)

  2. Bạn có thể sử dụng các lệnh CPU đặc biệt mà trình biên dịch của bạn có thể không tối ưu hóa được từ C. Ví dụ: trên 80386, bạn có thể sử dụng lệnh tiền tố "rep" + lệnh "movsb" để di chuyển N byte được chỉ định bằng cách đặt N vào số Đăng ký. Trình biên dịch tốt sẽ chỉ làm điều này cho bạn, nhưng bạn có thể đang ở trên một nền tảng thiếu trình biên dịch tốt. Lưu ý, ví dụ đó có xu hướng là một minh chứng xấu về tốc độ, nhưng kết hợp với căn chỉnh + hướng dẫn đơn vị lớn hơn, nó có thể nhanh hơn hầu hết mọi thứ khác trên một số CPU nhất định.

  3. Bỏ cuộn vòng lặp - các nhánh có thể khá tốn kém trên một số CPU, vì vậy việc hủy cuộn các vòng có thể làm giảm số nhánh. Đây cũng là một kỹ thuật tốt để kết hợp với các lệnh SIMD và các đơn vị có kích thước rất lớn.

Ví dụ: http://www.agner.org/optimize/#asmlib có một memcpytriển khai hoạt động hiệu quả nhất (một lượng rất nhỏ). Nếu bạn đọc mã nguồn, nó sẽ có rất nhiều mã lắp ráp nội tuyến rút ra tất cả ba kỹ thuật trên, chọn kỹ thuật nào trong số những kỹ thuật đó dựa trên CPU bạn đang chạy.

Lưu ý, có những tối ưu hóa tương tự cũng có thể được thực hiện để tìm các byte trong bộ đệm. strchr()và bạn bè thường sẽ nhanh hơn tay bạn cuộn tương đương. Điều này đặc biệt đúng với .NETJava . Ví dụ, trong .NET, tích hợp sẵn String.IndexOf()nhanh hơn nhiều so với tìm kiếm chuỗi Boyer – Moore , bởi vì nó sử dụng các kỹ thuật tối ưu hóa ở trên.


1
Chính Agner Fog mà bạn đang liên kết cũng đưa ra giả thuyết rằng việc bỏ cuộn vòng lặp là phản tác dụng trên các CPU hiện đại .

Hầu hết các CPU ngày nay đều có dự đoán rẽ nhánh tốt, điều này sẽ phủ nhận lợi ích của việc giải nén vòng lặp trong các trường hợp điển hình. Một trình biên dịch tối ưu hóa tốt đôi khi vẫn có thể sử dụng nó.
thomasrutter

5

Câu trả lời ngắn:

  • lấp đầy bộ nhớ cache
  • chuyển kích thước từ thay vì chuyển byte nếu có thể
  • Phép thuật SIMD

4

Tôi không biết liệu nó có thực sự được sử dụng trong bất kỳ triển khai thế giới thực nào hay không memcpy, nhưng tôi nghĩ Thiết bị của Duff xứng đáng được đề cập ở đây.

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

Lưu ý rằng điều trên không phải là memcpyvì nó cố tình không tăng tocon trỏ. Nó thực hiện một hoạt động hơi khác: ghi vào một thanh ghi ánh xạ bộ nhớ. Xem bài viết trên Wikipedia để biết chi tiết.


Thiết bị của Duff, hay chỉ là cơ chế nhảy ban đầu, là một cách sử dụng tốt để sao chép 1..3 (hoặc 1..7) byte đầu tiên để các con trỏ được căn chỉnh đến một ranh giới đẹp hơn, nơi có thể sử dụng các lệnh di chuyển bộ nhớ lớn hơn.
Daemin

@MarkByers: Đoạn mã minh họa một hoạt động hơi khác ( *tođề cập đến một thanh ghi được ánh xạ bộ nhớ và không được tăng thêm một cách có chủ ý - xem bài viết được liên kết đến). Như tôi nghĩ rằng tôi đã nói rõ ràng, câu trả lời của tôi không cố gắng cung cấp một hiệu quả memcpy, nó chỉ đơn giản đề cập đến một kỹ thuật khá tò mò.
NPE

@Daemin Đồng ý, như bạn đã nói bạn có thể bỏ qua do {} while () và công tắc sẽ được trình biên dịch dịch sang một bảng nhảy. Rất hữu ích khi bạn muốn chăm sóc các dữ liệu còn lại. Một cảnh báo nên được đề cập về thiết bị của Duff, có vẻ như trên các kiến ​​trúc mới hơn (x86 mới hơn), dự đoán rẽ nhánh hiệu quả đến mức thiết bị của Duff thực sự chậm hơn một vòng lặp đơn giản.
onemasse

1
Ồ không .. không phải thiết bị của Duff. Vui lòng không sử dụng thiết bị của Duff. Xin vui lòng. Sử dụng PGO và hãy để trình biên dịch của tôi thực hiện việc giải nén vòng lặp cho bạn khi nó có ý nghĩa.
Billy ONeal

Không, thiết bị của Duff chắc chắn không được sử dụng trong bất kỳ quá trình triển khai hiện đại nào.
gnasher729

3

Giống như những người khác nói các bản sao memcpy lớn hơn các khối 1 byte. Sao chép trong các phần có kích thước từ nhanh hơn nhiều. Tuy nhiên, hầu hết các triển khai sẽ tiến thêm một bước nữa và chạy một số hướng dẫn MOV (từ) trước khi lặp lại. Lợi thế của việc sao chép nói, 8 khối từ trên mỗi vòng lặp là bản thân vòng lặp rất tốn kém. Kỹ thuật này làm giảm số lượng các nhánh có điều kiện theo hệ số 8, tối ưu hóa bản sao cho các khối khổng lồ.


1
Tôi không nghĩ điều này là đúng. Bạn có thể mở vòng lặp, nhưng bạn không thể sao chép trong một lệnh duy nhất nhiều dữ liệu hơn địa chỉ tại một thời điểm trên kiến ​​trúc đích. Thêm vào đó, có overhead của unrolling vòng lặp quá ...
Billy Oneal

@Billy ONeal: Tôi không nghĩ đó là ý của VoidStar. Bằng cách thực hiện nhiều lệnh di chuyển liên tiếp, chi phí đếm số lượng đơn vị sẽ giảm xuống.
wallyk

@Billy ONeal: Bạn đang thiếu điểm. Mỗi từ 1 từ giống như MOV, JMP, MOV, JMP, v.v. Bạn có thể thực hiện MOV MOV MOV MOV JMP ở đâu. Tôi đã viết mempcy trước và tôi đã làm chuẩn rất nhiều cách để làm việc đó;)
VoidStar

@wallyk: Có lẽ. Nhưng anh ấy nói "sao chép các khối lớn hơn nữa" - điều này không thực sự khả thi. Nếu ý của anh ấy là hủy cuộn vòng lặp, thì anh ấy nên nói "hầu hết các triển khai đều tiến thêm một bước nữa và bỏ cuộn vòng lặp." Câu trả lời như đã viết tốt nhất là gây hiểu lầm, tệ nhất là sai.
Billy ONeal

@VoidStar: Đồng ý --- bây giờ tốt hơn rồi. +1.
Billy ONeal

2

Những câu trả lời là rất lớn, nhưng nếu bạn vẫn muốn thực hiện một nhanh memcpycho mình, có một bài viết trên blog thú vị về memcpy nhanh, memcpy nhanh trong 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;
}

Thậm chí, nó có thể tốt hơn với việc tối ưu hóa truy cập bộ nhớ.


1

Bởi vì giống như nhiều quy trình thư viện, nó đã được tối ưu hóa cho kiến ​​trúc bạn đang chạy. Những người khác đã đăng các kỹ thuật khác nhau có thể được sử dụng.

Với sự lựa chọn, hãy sử dụng các quy trình thư viện thay vì cuộn của riêng bạn. Đây là một biến thể của DRY mà tôi gọi là DRO (Không lặp lại người khác). Ngoài ra, các quy trình thư viện ít có khả năng bị sai hơn so với việc triển khai của chính bạn.

Tôi đã thấy những người kiểm tra truy cập bộ nhớ phàn nàn về việc đọc vượt quá giới hạn trên bộ nhớ đệm hoặc bộ đệm chuỗi không phải là bội số của kích thước từ. Đây là kết quả của việc tối ưu hóa đang được sử dụng.


0

Bạn có thể xem việc triển khai MacOS của memset, memcpy và memmove.

Tại thời điểm khởi động, hệ điều hành xác định bộ xử lý mà nó đang chạy. Nó đã tích hợp mã được tối ưu hóa đặc biệt cho từng bộ xử lý được hỗ trợ và tại thời điểm khởi động sẽ lưu trữ một lệnh jmp cho đúng mã ở một vị trí cố định chỉ đọc / chỉ.

Việc triển khai memset C, memcpy và memmove chỉ là một bước nhảy đến vị trí cố định đó.

Việc triển khai sử dụng mã khác nhau tùy thuộc vào sự liên kết của nguồn và đích cho memcpy và memmove. Rõ ràng là chúng sử dụng tất cả các khả năng vectơ có sẵn. Họ cũng sử dụng các biến thể không lưu vào bộ nhớ đệm khi bạn sao chép số lượng lớn dữ liệu và có hướng dẫn để giảm thiểu thời gian chờ đợi cho bảng trang. Nó không chỉ là mã của trình hợp dịch, mà là mã của trình hợp dịch được viết bởi một người có kiến ​​thức cực kỳ tốt về từng kiến ​​trúc bộ xử lý.

Intel cũng bổ sung các hướng dẫn trình hợp dịch có thể làm cho các hoạt động chuỗi nhanh hơn. Ví dụ với một lệnh để hỗ trợ strstr mà 256 byte so sánh trong một chu kỳ.


Phiên bản mã nguồn mở của Apple memset / memcpy / memmove chỉ là phiên bản chung sẽ chậm hơn rất nhiều so với phiên bản thực sử dụng SIMD
phuclv
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.