Thay thế bộ đếm vòng lặp 32 bit bằng 64 bit giới thiệu độ lệch hiệu năng điên rồ bằng _mm_popcnt_u64 trên CPU Intel


1424

Tôi đang tìm cách nhanh nhất cho popcountcác mảng dữ liệu lớn. Tôi đã gặp một hiệu ứng rất kỳ lạ : Thay đổi biến vòng lặp từ unsignedđể uint64_tlàm cho hiệu suất giảm 50% trên PC của tôi.

Điểm chính xác

#include <iostream>
#include <chrono>
#include <x86intrin.h>

int main(int argc, char* argv[]) {

    using namespace std;
    if (argc != 2) {
       cerr << "usage: array_size in MB" << endl;
       return -1;
    }

    uint64_t size = atol(argv[1])<<20;
    uint64_t* buffer = new uint64_t[size/8];
    char* charbuffer = reinterpret_cast<char*>(buffer);
    for (unsigned i=0; i<size; ++i)
        charbuffer[i] = rand()%256;

    uint64_t count,duration;
    chrono::time_point<chrono::system_clock> startP,endP;
    {
        startP = chrono::system_clock::now();
        count = 0;
        for( unsigned k = 0; k < 10000; k++){
            // Tight unrolled loop with unsigned
            for (unsigned i=0; i<size/8; i+=4) {
                count += _mm_popcnt_u64(buffer[i]);
                count += _mm_popcnt_u64(buffer[i+1]);
                count += _mm_popcnt_u64(buffer[i+2]);
                count += _mm_popcnt_u64(buffer[i+3]);
            }
        }
        endP = chrono::system_clock::now();
        duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
        cout << "unsigned\t" << count << '\t' << (duration/1.0E9) << " sec \t"
             << (10000.0*size)/(duration) << " GB/s" << endl;
    }
    {
        startP = chrono::system_clock::now();
        count=0;
        for( unsigned k = 0; k < 10000; k++){
            // Tight unrolled loop with uint64_t
            for (uint64_t i=0;i<size/8;i+=4) {
                count += _mm_popcnt_u64(buffer[i]);
                count += _mm_popcnt_u64(buffer[i+1]);
                count += _mm_popcnt_u64(buffer[i+2]);
                count += _mm_popcnt_u64(buffer[i+3]);
            }
        }
        endP = chrono::system_clock::now();
        duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
        cout << "uint64_t\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
             << (10000.0*size)/(duration) << " GB/s" << endl;
    }

    free(charbuffer);
}

Như bạn thấy, chúng tôi tạo ra một bộ đệm dữ liệu ngẫu nhiên, với kích thước là xmegabyte xđược đọc từ dòng lệnh. Sau đó, chúng tôi lặp lại bộ đệm và sử dụng phiên bản không kiểm soát của popcountnội tại x86 để thực hiện số đếm. Để có được kết quả chính xác hơn, chúng tôi thực hiện 10.000 lần. Chúng tôi đo thời gian cho dân số. Trong trường hợp trên, biến vòng lặp bên trong unsigned, trong trường hợp thấp hơn, biến vòng lặp bên trong là uint64_t. Tôi nghĩ rằng điều này sẽ không có sự khác biệt, nhưng ngược lại là trường hợp.

Kết quả (hoàn toàn điên rồ)

Tôi biên dịch nó như thế này (phiên bản g ++: Ubuntu 4.8.2-19ubfox1):

g++ -O3 -march=native -std=c++11 test.cpp -o test

Dưới đây là kết quả trên CPU Haswell Core i7-4770K của tôi @ 3.50 GHz, đang chạy test 1(vì vậy dữ liệu ngẫu nhiên 1 MB):

  • không dấu 41959360000 0.401554 giây 26.113 GB / s
  • uint64_t 41959360000 0.759822 giây 13.8003 GB / s

Như bạn thấy, thông lượng của uint64_tphiên bản chỉ bằng một nửa so với unsignedphiên bản! Vấn đề dường như là sự lắp ráp khác nhau được tạo ra, nhưng tại sao? Đầu tiên, tôi nghĩ về một lỗi trình biên dịch, vì vậy tôi đã thử clang++( phiên bản Ubuntu Clang 3.4-1ubfox3):

clang++ -O3 -march=native -std=c++11 teest.cpp -o test

Kết quả: test 1

  • không dấu 41959360000 0.398293 giây 26.3267 GB / s
  • uint64_t 41959360000 0,680954 giây 15,3986 GB / s

Vì vậy, nó gần như là kết quả tương tự và vẫn còn lạ. Nhưng bây giờ nó trở nên siêu lạ. Tôi thay thế kích thước bộ đệm được đọc từ đầu vào bằng một hằng số 1, vì vậy tôi thay đổi:

uint64_t size = atol(argv[1]) << 20;

đến

uint64_t size = 1 << 20;

Vì vậy, trình biên dịch bây giờ biết kích thước bộ đệm tại thời gian biên dịch. Có lẽ nó có thể thêm một số tối ưu hóa! Dưới đây là những con số cho g++:

  • không dấu 41959360000 0.509156 giây 20.5944 GB / s
  • uint64_t 41959360000 0,508673 giây 20,6139 GB / s

Bây giờ, cả hai phiên bản đều nhanh như nhau. Tuy nhiên, unsigned thậm chí còn chậm hơn ! Nó giảm từ 26để 20 GB/s, do đó thay thế một tổ chức phi liên tục bởi một dẫn giá trị không đổi vào một deoptimization . Nghiêm túc mà nói, tôi không có manh mối gì đang diễn ra ở đây! Nhưng bây giờ clang++với phiên bản mới:

  • không dấu 41959360000 0.677009 giây 15.4884 GB / s
  • uint64_t 41959360000 0.676909 giây 15.4906 GB / s

Đợi đã, cái gì? Bây giờ, cả hai phiên bản giảm xuống mức chậm 15 GB / s. Do đó, thay thế một hằng số bằng một giá trị không đổi thậm chí dẫn đến mã chậm trong cả hai trường hợp cho Clang!

Tôi đã nhờ một đồng nghiệp có CPU Ivy Bridge biên dịch điểm chuẩn của tôi. Anh ta nhận được kết quả tương tự, vì vậy nó dường như không phải là Haswell. Bởi vì hai trình biên dịch tạo ra kết quả lạ ở đây, nó dường như không phải là một lỗi trình biên dịch. Chúng tôi không có CPU AMD ở đây, vì vậy chúng tôi chỉ có thể thử nghiệm với Intel.

Điên rồ hơn, làm ơn!

Lấy ví dụ đầu tiên (ví dụ với atol(argv[1])) và đặt a statictrước biến, nghĩa là:

static uint64_t size=atol(argv[1])<<20;

Đây là kết quả của tôi trong g ++:

  • không dấu 41959360000 0.396728 giây 26.4306 GB / s
  • uint64_t 41959360000 0.509484 giây 20.5811 GB / s

Yay, một sự thay thế khác . Chúng tôi vẫn có tốc độ 26 GB / giây nhanh u32, nhưng chúng tôi đã có được u64ít nhất từ ​​13 GB / giây đến phiên bản 20 GB / giây! Trên PC của đồng nghiệp của tôi, u64phiên bản thậm chí còn nhanh hơn u32phiên bản, mang lại kết quả nhanh nhất trong tất cả. Đáng buồn thay, điều này chỉ hoạt động cho g++, clang++dường như không quan tâm static.

Câu hỏi của tôi

Bạn có thể giải thích những kết quả này? Đặc biệt:

  • Làm thế nào có thể có một sự khác biệt như vậy giữa u32u64?
  • Làm thế nào có thể thay thế một hằng số bằng một kích thước bộ đệm không đổi kích hoạt mã tối ưu ít hơn ?
  • Làm thế nào để chèn statictừ khóa làm cho u64vòng lặp nhanh hơn? Thậm chí nhanh hơn mã gốc trên máy tính của đồng nghiệp của tôi!

Tôi biết rằng tối ưu hóa là một lãnh thổ phức tạp, tuy nhiên, tôi không bao giờ nghĩ rằng những thay đổi nhỏ như vậy có thể dẫn đến chênh lệch 100% về thời gian thực hiện và các yếu tố nhỏ như kích thước bộ đệm không đổi có thể trộn lại kết quả hoàn toàn. Tất nhiên, tôi luôn muốn có phiên bản có thể đạt 26 GB / s. Cách đáng tin cậy duy nhất tôi có thể nghĩ đến là sao chép dán cụm cho trường hợp này và sử dụng lắp ráp nội tuyến. Đây là cách duy nhất tôi có thể thoát khỏi trình biên dịch dường như phát điên với những thay đổi nhỏ. Bạn nghĩ sao? Có cách nào khác để lấy mã với hiệu suất cao nhất không?

Việc tháo gỡ

Đây là sự tháo gỡ cho các kết quả khác nhau:

Phiên bản 26 GB / s từ g ++ / u32 / non-const bufsize :

0x400af8:
lea 0x1(%rdx),%eax
popcnt (%rbx,%rax,8),%r9
lea 0x2(%rdx),%edi
popcnt (%rbx,%rcx,8),%rax
lea 0x3(%rdx),%esi
add %r9,%rax
popcnt (%rbx,%rdi,8),%rcx
add $0x4,%edx
add %rcx,%rax
popcnt (%rbx,%rsi,8),%rcx
add %rcx,%rax
mov %edx,%ecx
add %rax,%r14
cmp %rbp,%rcx
jb 0x400af8

Phiên bản 13 GB / s từ g ++ / u64 / non-const bufsize :

0x400c00:
popcnt 0x8(%rbx,%rdx,8),%rcx
popcnt (%rbx,%rdx,8),%rax
add %rcx,%rax
popcnt 0x10(%rbx,%rdx,8),%rcx
add %rcx,%rax
popcnt 0x18(%rbx,%rdx,8),%rcx
add $0x4,%rdx
add %rcx,%rax
add %rax,%r12
cmp %rbp,%rdx
jb 0x400c00

Phiên bản 15 GB / s từ clang ++ / u64 / non-const bufsize :

0x400e50:
popcnt (%r15,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r15,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r15,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r15,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp %rbp,%rcx
jb 0x400e50

Phiên bản 20 GB / s từ g ++ / u32 & u64 / const bufsize :

0x400a68:
popcnt (%rbx,%rdx,1),%rax
popcnt 0x8(%rbx,%rdx,1),%rcx
add %rax,%rcx
popcnt 0x10(%rbx,%rdx,1),%rax
add %rax,%rcx
popcnt 0x18(%rbx,%rdx,1),%rsi
add $0x20,%rdx
add %rsi,%rcx
add %rcx,%rbp
cmp $0x100000,%rdx
jne 0x400a68

Phiên bản 15 GB / s từ clang ++ / u32 & u64 / const bufsize :

0x400dd0:
popcnt (%r14,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r14,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r14,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r14,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp $0x20000,%rcx
jb 0x400dd0

Điều thú vị là phiên bản nhanh nhất (26 GB / giây) cũng dài nhất! Nó dường như là giải pháp duy nhất sử dụng lea. Một số phiên bản sử dụng jbđể nhảy, số khác sử dụng jne. Nhưng ngoài điều đó, tất cả các phiên bản dường như có thể so sánh được. Tôi không thấy khoảng cách hiệu suất 100% có thể bắt nguồn từ đâu, nhưng tôi không quá giỏi trong việc giải mã lắp ráp. Phiên bản chậm nhất (13 GB / giây) trông thậm chí rất ngắn và tốt. Bất cứ ai có thể giải thích điều này?

Bài học kinh nghiệm

Không có vấn đề gì câu trả lời cho câu hỏi này sẽ là; Tôi đã học được rằng trong các vòng lặp thực sự nóng, mọi chi tiết đều có thể quan trọng, ngay cả những chi tiết dường như không có bất kỳ mối liên hệ nào với mã nóng . Tôi chưa bao giờ nghĩ về loại sẽ sử dụng cho một biến vòng lặp, nhưng như bạn thấy một thay đổi nhỏ như vậy có thể tạo ra sự khác biệt 100% ! Ngay cả kiểu lưu trữ của bộ đệm cũng có thể tạo ra sự khác biệt lớn, như chúng ta đã thấy với việc chèn statictừ khóa vào trước biến kích thước! Trong tương lai, tôi sẽ luôn kiểm tra các lựa chọn thay thế khác nhau trên các trình biên dịch khác nhau khi viết các vòng lặp thực sự chặt chẽ và nóng rất quan trọng đối với hiệu năng hệ thống.

Điều thú vị là sự khác biệt hiệu năng vẫn còn rất cao mặc dù tôi đã không kiểm soát vòng lặp bốn lần. Vì vậy, ngay cả khi bạn không đăng ký, bạn vẫn có thể bị ảnh hưởng bởi độ lệch hiệu suất lớn. Khá thú vị.


8
RẤT NHIỀU Ý KIẾN! Bạn có thể xem chúng trong trò chuyện và thậm chí để lại của riêng bạn ở đó nếu bạn muốn, nhưng xin vui lòng không thêm bất kỳ ở đây nữa!
Shog9

3
Cũng xem GCC số 62011, Phụ thuộc dữ liệu sai trong hướng dẫn popcnt . Một số người khác cung cấp nó, nhưng dường như nó đã bị mất trong quá trình dọn dẹp.
jww

Tôi không thể nói nhưng là một trong những điều bất đồng cho phiên bản với tĩnh? Nếu không, bạn có thể chỉnh sửa bài viết và thêm nó?
Kelly S. Pháp

Câu trả lời:


1552

Thủ phạm: Sự phụ thuộc dữ liệu sai (và trình biên dịch thậm chí không biết về nó)

Trên bộ xử lý Sandy / Ivy Bridge và Haswell, hướng dẫn:

popcnt  src, dest

dường như có một sự phụ thuộc sai vào thanh ghi đích dest. Mặc dù hướng dẫn chỉ ghi vào nó, hướng dẫn sẽ đợi cho đến khi destsẵn sàng trước khi thực hiện. Sự phụ thuộc sai này là (hiện tại) được Intel ghi nhận là erratum HSD146 (Haswell)SKL029 (Skylake)

Skylake đã sửa lỗi này cho lzcnttzcnt .
Cannon Lake (và Ice Lake) đã sửa lỗi này cho popcnt.
bsf/ bsrcó một phụ thuộc đầu ra thực sự: đầu ra không được sửa đổi cho đầu vào = 0. (Nhưng không có cách nào để tận dụng lợi thế đó bằng nội tại - chỉ có tài liệu AMD và trình biên dịch không phơi bày nó.)

(Có, tất cả các hướng dẫn này đều chạy trên cùng một đơn vị thực thi ).


Sự phụ thuộc này không chỉ giữ 4 popcntgiây từ một vòng lặp đơn. Nó có thể mang các vòng lặp lặp đi lặp lại khiến bộ xử lý không thể song song hóa các lần lặp khác nhau.

Các unsignedso với uint64_tvà các chỉnh sửa khác không ảnh hưởng trực tiếp đến vấn đề. Nhưng chúng ảnh hưởng đến cấp phát thanh ghi gán các thanh ghi cho các biến.

Trong trường hợp của bạn, tốc độ là kết quả trực tiếp của những gì bị mắc kẹt trong chuỗi phụ thuộc (sai) tùy thuộc vào những gì người cấp phát đăng ký quyết định làm.

  • 13 GB / s có chuỗi: popcnt- add- popcnt- popcnt→ lần lặp tiếp theo
  • 15 GB / s có chuỗi: popcnt- add- popcnt- add→ lần lặp tiếp theo
  • 20 GB / s có chuỗi: popcnt- popcnt→ lần lặp tiếp theo
  • 26 GB / s có chuỗi: popcnt- popcnt→ lần lặp tiếp theo

Sự khác biệt giữa 20 GB / s và 26 GB / s dường như là một yếu tố nhỏ của việc đánh địa chỉ gián tiếp. Dù bằng cách nào, bộ xử lý bắt đầu gặp các tắc nghẽn khác khi bạn đạt đến tốc độ này.


Để kiểm tra điều này, tôi đã sử dụng lắp ráp nội tuyến để bỏ qua trình biên dịch và lấy chính xác lắp ráp mà tôi muốn. Tôi cũng phân tách countbiến để phá vỡ tất cả các phụ thuộc khác có thể gây rối với điểm chuẩn.

Đây là kết quả:

Sandy Bridge Xeon @ 3,5 GHz: (có thể tìm thấy mã kiểm tra đầy đủ ở phía dưới)

  • GCC 4.6.3: g++ popcnt.cpp -std=c++0x -O3 -save-temps -march=native
  • Ubuntu 12

Các thanh ghi khác nhau: 18.6195 GB / s

.L4:
    movq    (%rbx,%rax,8), %r8
    movq    8(%rbx,%rax,8), %r9
    movq    16(%rbx,%rax,8), %r10
    movq    24(%rbx,%rax,8), %r11
    addq    $4, %rax

    popcnt %r8, %r8
    add    %r8, %rdx
    popcnt %r9, %r9
    add    %r9, %rcx
    popcnt %r10, %r10
    add    %r10, %rdi
    popcnt %r11, %r11
    add    %r11, %rsi

    cmpq    $131072, %rax
    jne .L4

Đăng ký tương tự: 8.49272 GB / s

.L9:
    movq    (%rbx,%rdx,8), %r9
    movq    8(%rbx,%rdx,8), %r10
    movq    16(%rbx,%rdx,8), %r11
    movq    24(%rbx,%rdx,8), %rbp
    addq    $4, %rdx

    # This time reuse "rax" for all the popcnts.
    popcnt %r9, %rax
    add    %rax, %rcx
    popcnt %r10, %rax
    add    %rax, %rsi
    popcnt %r11, %rax
    add    %rax, %r8
    popcnt %rbp, %rax
    add    %rax, %rdi

    cmpq    $131072, %rdx
    jne .L9

Đăng ký tương tự với chuỗi bị hỏng: 17.8869 GB / s

.L14:
    movq    (%rbx,%rdx,8), %r9
    movq    8(%rbx,%rdx,8), %r10
    movq    16(%rbx,%rdx,8), %r11
    movq    24(%rbx,%rdx,8), %rbp
    addq    $4, %rdx

    # Reuse "rax" for all the popcnts.
    xor    %rax, %rax    # Break the cross-iteration dependency by zeroing "rax".
    popcnt %r9, %rax
    add    %rax, %rcx
    popcnt %r10, %rax
    add    %rax, %rsi
    popcnt %r11, %rax
    add    %rax, %r8
    popcnt %rbp, %rax
    add    %rax, %rdi

    cmpq    $131072, %rdx
    jne .L14

Vì vậy, những gì đã xảy ra với trình biên dịch?

Có vẻ như cả GCC và Visual Studio đều không biết rằng popcntcó sự phụ thuộc sai lầm như vậy. Tuy nhiên, những phụ thuộc sai này không phải là hiếm. Đó chỉ là vấn đề liệu trình biên dịch có nhận thức được nó hay không.

popcntkhông chính xác là hướng dẫn được sử dụng nhiều nhất. Vì vậy, không có gì đáng ngạc nhiên khi một trình biên dịch chính có thể bỏ lỡ điều gì đó như thế này. Dường như không có tài liệu nào đề cập đến vấn đề này. Nếu Intel không tiết lộ nó, thì không ai ở bên ngoài sẽ biết cho đến khi ai đó tình cờ gặp nó.

( Cập nhật: Kể từ phiên bản 4.9.2 , GCC nhận thức được sự phụ thuộc sai này và tạo mã để bù lại khi tối ưu hóa được bật. Các trình biên dịch chính từ các nhà cung cấp khác, bao gồm Clang, MSVC và thậm chí ICC của Intel vẫn chưa biết lỗi vi kiến ​​trúc này và sẽ không phát ra mã bù cho nó.)

Tại sao CPU có sự phụ thuộc sai như vậy?

Chúng ta có thể suy đoán: nó chạy trên các đơn vị thực hiện tương tự như bsf/ bsrlàm có một sự phụ thuộc đầu ra. ( POPCNT được triển khai trong phần cứng như thế nào? ). Đối với các hướng dẫn đó, Intel ghi lại kết quả số nguyên cho đầu vào = 0 là "không xác định" (với ZF = 1), nhưng phần cứng của Intel thực sự mang lại sự đảm bảo mạnh mẽ hơn để tránh phá vỡ phần mềm cũ: đầu ra không được sửa đổi. AMD ghi lại hành vi này.

Có lẽ bằng cách nào đó đã bất tiện khi tạo ra một số lỗi cho đơn vị thực thi này phụ thuộc vào đầu ra nhưng những cái khác thì không.

Bộ xử lý AMD dường như không có sự phụ thuộc sai này.


Mã kiểm tra đầy đủ dưới đây để tham khảo:

#include <iostream>
#include <chrono>
#include <x86intrin.h>

int main(int argc, char* argv[]) {

   using namespace std;
   uint64_t size=1<<20;

   uint64_t* buffer = new uint64_t[size/8];
   char* charbuffer=reinterpret_cast<char*>(buffer);
   for (unsigned i=0;i<size;++i) charbuffer[i]=rand()%256;

   uint64_t count,duration;
   chrono::time_point<chrono::system_clock> startP,endP;
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "popcnt %4, %4  \n\t"
                "add %4, %0     \n\t"
                "popcnt %5, %5  \n\t"
                "add %5, %1     \n\t"
                "popcnt %6, %6  \n\t"
                "add %6, %2     \n\t"
                "popcnt %7, %7  \n\t"
                "add %7, %3     \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "No Chain\t" << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "popcnt %4, %%rax   \n\t"
                "add %%rax, %0      \n\t"
                "popcnt %5, %%rax   \n\t"
                "add %%rax, %1      \n\t"
                "popcnt %6, %%rax   \n\t"
                "add %%rax, %2      \n\t"
                "popcnt %7, %%rax   \n\t"
                "add %%rax, %3      \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
                : "rax"
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "Chain 4   \t"  << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "xor %%rax, %%rax   \n\t"   // <--- Break the chain.
                "popcnt %4, %%rax   \n\t"
                "add %%rax, %0      \n\t"
                "popcnt %5, %%rax   \n\t"
                "add %%rax, %1      \n\t"
                "popcnt %6, %%rax   \n\t"
                "add %%rax, %2      \n\t"
                "popcnt %7, %%rax   \n\t"
                "add %%rax, %3      \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
                : "rax"
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "Broken Chain\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }

   free(charbuffer);
}

Một điểm chuẩn thú vị không kém có thể được tìm thấy ở đây: http://pastebin.com/kbzgL8si
Điểm chuẩn này thay đổi số lượng popcnts trong chuỗi phụ thuộc (sai).

False Chain 0:  41959360000 0.57748 sec     18.1578 GB/s
False Chain 1:  41959360000 0.585398 sec    17.9122 GB/s
False Chain 2:  41959360000 0.645483 sec    16.2448 GB/s
False Chain 3:  41959360000 0.929718 sec    11.2784 GB/s
False Chain 4:  41959360000 1.23572 sec     8.48557 GB/s

3
Chào các bạn! Rất nhiều ý kiến ​​trong quá khứ ở đây; trước khi để lại một cái mới, xin vui lòng xem lại kho lưu trữ .
Shog9

1
@ JustinL.it có vẻ như sự cố cụ thể này đã được khắc phục ở Clang kể từ ngày 7.0
Dan M.

@PeterCordes Tôi không nghĩ đó là đơn vị thực thi nhiều như nó là bộ lập lịch. Đó là lịch trình theo dõi các phụ thuộc. Và để làm điều đó, các hướng dẫn được nhóm thành một số "lớp hướng dẫn", mỗi hướng dẫn được xử lý giống hệt nhau bởi bộ lập lịch. Do đó, tất cả các hướng dẫn "chậm-chu kỳ" 3 chu kỳ đã được ném vào cùng một "lớp" cho mục đích lập lịch hướng dẫn.
Bí ẩn

@Mysticial: Bây giờ bạn vẫn nghĩ như vậy? Điều đó hợp lý, nhưng imul dst, src, immkhông có sự phụ thuộc đầu ra và cũng không chậm lea. Không pdep, nhưng VEX được mã hóa với 2 toán hạng đầu vào. Đồng ý rằng đây không phải là đơn vị thực hiện bản thân mà làm cho dep false; đó là đến RAT và phát hành / đổi tên giai đoạn khi nó đổi tên các toán hạng đăng ký kiến ​​trúc thành các thanh ghi vật lý. Có lẽ nó cần một bảng mã uop -> mẫu phụ thuộc và các lựa chọn cổng, và nhóm tất cả các uops cho cùng một đơn vị thực thi cùng nhau đơn giản hóa bảng đó. Đó là những gì tôi muốn nói chi tiết hơn.
Peter Cordes

Hãy cho tôi biết nếu bạn muốn tôi chỉnh sửa nó thành câu trả lời của bạn, hoặc nếu bạn muốn đặt nó trở lại để nói điều gì đó giống như những gì bạn nói ban đầu về lịch trình. Việc SKL bỏ dep dep cho lzcnt / tzcnt nhưng không phải popcnt sẽ cho chúng ta biết điều gì đó, nhưng IDK thì sao. Một dấu hiệu khả dĩ khác cho thấy nó đổi tên / liên quan đến RAT là SKL hủy kiểm tra chế độ địa chỉ được lập chỉ mục làm nguồn bộ nhớ cho lzcnt / tzcnt nhưng không phải là popcnt. Rõ ràng, đơn vị đổi tên phải tạo ra các vòng lặp, mặc dù back-end có thể đại diện.
Peter Cordes

50

Tôi đã mã hóa một chương trình C tương đương để thử nghiệm và tôi có thể xác nhận hành vi kỳ lạ này. Hơn nữa, gcctin rằng số nguyên 64 bit (có lẽ nên là số nguyên size_t...) sẽ tốt hơn, vì sử dụng uint_fast32_tgcc để sử dụng uint 64 bit.

Tôi đã thực hiện một số thao tác với phần lắp ráp:
Đơn giản chỉ cần lấy phiên bản 32 bit, thay thế tất cả các hướng dẫn / thanh ghi 32 bit bằng phiên bản 64 bit trong vòng lặp popcount bên trong của chương trình. Quan sát: mã chỉ nhanh như phiên bản 32 bit!

Đây rõ ràng là một hack, vì kích thước của biến không thực sự là 64 bit, vì các phần khác của chương trình vẫn sử dụng phiên bản 32 bit, nhưng miễn là vòng lặp popcount bên trong chi phối hiệu suất, đây là một khởi đầu tốt .

Sau đó, tôi đã sao chép mã vòng lặp bên trong từ phiên bản 32 bit của chương trình, hack nó thành 64 bit, xử lý các thanh ghi để thay thế cho vòng lặp bên trong của phiên bản 64 bit. Mã này cũng chạy nhanh như phiên bản 32 bit.

Kết luận của tôi là đây là trình lập lịch lệnh xấu của trình biên dịch, không phải là lợi thế tốc độ / độ trễ thực tế của các lệnh 32 bit.

(Hãy cẩn thận: Tôi đã hack lắp ráp, có thể đã phá vỡ thứ gì đó mà không nhận thấy. Tôi không nghĩ vậy.)


1
Hơn nữa, gcc tin rằng số nguyên 64 bit [Mạnh] sẽ tốt hơn, vì sử dụng uint_fast32_t khiến gcc sử dụng uint 64 bit. Thật không may, và với sự tiếc nuối của tôi, không có phép thuật và không có sự thâm nhập mã sâu đằng sau các loại này. Tôi chưa thấy họ cung cấp bất kỳ cách nào khác ngoài các typedefs duy nhất cho mọi nơi có thể và mọi chương trình trên toàn bộ nền tảng. Có thể đã có khá nhiều suy nghĩ đằng sau sự lựa chọn chính xác của các loại, nhưng định nghĩa cho mỗi loại trong số chúng có thể không phù hợp với mọi ứng dụng sẽ có. Một số đọc thêm: stackoverflow.com/q/4116297 .
Keno

2
@Keno Đó là vì sizeof(uint_fast32_t)phải được xác định. Nếu bạn cho phép nó không tồn tại, bạn có thể thực hiện thủ thuật đó, nhưng điều đó chỉ có thể được thực hiện với một phần mở rộng trình biên dịch.
wizzwizz4

25

Đây không phải là một câu trả lời, nhưng thật khó để đọc nếu tôi đưa kết quả vào bình luận.

Tôi nhận được những kết quả này với máy Mac Pro ( West 4.0.3 6-Cores Xeon 3,33 GHz). Tôi đã biên dịch nó với clang -O3 -msse4 -lstdc++ a.cpp -o a(-O2 nhận được kết quả tương tự).

kêu vang với uint64_t size=atol(argv[1])<<20;

unsigned    41950110000 0.811198 sec    12.9263 GB/s
uint64_t    41950110000 0.622884 sec    16.8342 GB/s

kêu vang với uint64_t size=1<<20;

unsigned    41950110000 0.623406 sec    16.8201 GB/s
uint64_t    41950110000 0.623685 sec    16.8126 GB/s

Tôi cũng đã cố gắng:

  1. Đảo ngược thứ tự kiểm tra, kết quả là như nhau để nó loại trừ yếu tố bộ đệm.
  2. fortuyên bố ngược lại : for (uint64_t i=size/8;i>0;i-=4). Điều này cho kết quả tương tự và chứng minh trình biên dịch đủ thông minh để không chia kích thước cho 8 mỗi lần lặp (như mong đợi).

Đây là phỏng đoán hoang dã của tôi:

Yếu tố tốc độ có ba phần:

  • bộ đệm mã: uint64_tphiên bản có kích thước mã lớn hơn, nhưng điều này không ảnh hưởng đến CPU Xeon của tôi. Điều này làm cho phiên bản 64 bit chậm hơn.

  • Hướng dẫn sử dụng. Lưu ý không chỉ số vòng lặp, mà bộ đệm được truy cập với chỉ số 32 bit và 64 bit trên hai phiên bản. Truy cập một con trỏ với phần bù 64 bit yêu cầu một thanh ghi và địa chỉ 64 bit chuyên dụng, trong khi bạn có thể sử dụng ngay lập tức cho phần bù 32 bit. Điều này có thể làm cho phiên bản 32 bit nhanh hơn.

  • Các hướng dẫn chỉ được phát ra trên trình biên dịch 64 bit (nghĩa là tìm nạp trước). Điều này làm cho 64-bit nhanh hơn.

Ba yếu tố phù hợp với kết quả dường như mâu thuẫn quan sát được.


4
Thật thú vị, bạn có thể thêm phiên bản trình biên dịch và cờ trình biên dịch không? Điều tốt nhất là trên máy của bạn, kết quả được quay lại, tức là sử dụng u64 nhanh hơn . Cho đến bây giờ, tôi chưa bao giờ nghĩ về loại biến vòng lặp của mình, nhưng có vẻ như tôi phải suy nghĩ lại lần sau :).
gex tự tử

2
@gex tự do: Tôi sẽ không gọi một bước nhảy từ 16.8201 đến 16.8126 làm cho nó "nhanh hơn".
dùng541686

2
@Mehrdad: Ý tôi là bước nhảy giữa 12.916.8vì thế unsignednhanh hơn ở đây. Trong điểm chuẩn của tôi, điều ngược lại là trường hợp, tức là 26 cho unsigned, 15 chouint64_t
gex tự tử

@gexinf Bạn có nhận thấy sự khác biệt trong bộ đệm địa chỉ [i] không?
Ngắt không thể che dấu

@Calvin: Không, ý bạn là gì?
gex tự tử

10

Tôi không thể đưa ra một câu trả lời có thẩm quyền, nhưng cung cấp một cái nhìn tổng quan về nguyên nhân có thể. Tham chiếu này cho thấy khá rõ rằng đối với các hướng dẫn trong phần thân của vòng lặp của bạn có tỷ lệ 3: 1 giữa độ trễ và thông lượng. Nó cũng cho thấy những ảnh hưởng của nhiều công văn. Vì có (cho hoặc nhận) ba đơn vị số nguyên trong bộ xử lý x86 hiện đại, nên thường có thể gửi ba lệnh cho mỗi chu kỳ.

Vì vậy, giữa đường ống cao điểm và hiệu suất gửi nhiều lần và sự thất bại của các cơ chế này, chúng tôi có hệ số sáu trong hiệu suất. Một điều khá nổi tiếng là sự phức tạp của tập lệnh x86 khiến cho việc phá vỡ kỳ quặc xảy ra khá dễ dàng. Tài liệu trên có một ví dụ tuyệt vời:

Hiệu suất Pentium 4 cho các ca làm việc đúng 64 bit rất kém. Dịch chuyển trái 64 bit cũng như tất cả các ca 32 bit có hiệu suất chấp nhận được. Dường như đường dẫn dữ liệu từ 32 bit trên đến 32 bit dưới của ALU không được thiết kế tốt.

Cá nhân tôi đã gặp phải một trường hợp kỳ lạ khi một vòng lặp nóng chạy chậm hơn đáng kể trên lõi cụ thể của chip bốn lõi (AMD nếu tôi nhớ lại). Chúng tôi thực sự có hiệu suất tốt hơn trên phép tính giảm bản đồ bằng cách tắt lõi đó.

Ở đây tôi đoán là sự tranh chấp đối với các đơn vị số nguyên: rằng bộ popcntđếm vòng lặp và tính toán địa chỉ hoàn toàn có thể chạy ở tốc độ tối đa với bộ đếm rộng 32 bit, nhưng bộ đếm 64 bit gây ra tranh chấp và quầy hàng đường ống. Vì chỉ có tổng cộng khoảng 12 chu kỳ, có khả năng là 4 chu kỳ với nhiều lần gửi, mỗi lần thực hiện thân vòng lặp, một gian hàng duy nhất có thể ảnh hưởng hợp lý đến thời gian chạy theo hệ số 2.

Sự thay đổi gây ra bằng cách sử dụng một biến tĩnh, mà tôi đoán chỉ gây ra sự sắp xếp lại các hướng dẫn nhỏ, là một manh mối khác cho thấy mã 32 bit đang ở một điểm bùng phát nào đó để tranh chấp.

Tôi biết đây không phải là một phân tích nghiêm ngặt, nhưng nó một lời giải thích hợp lý.


2
Thật không may, kể từ đó (Core 2?) Hầu như không có sự khác biệt về hiệu năng giữa các hoạt động số nguyên 32 bit và 64 bit ngoại trừ phép nhân / chia - vốn không có trong mã này.
Bí ẩn

@Gene: Lưu ý rằng tất cả các phiên bản lưu trữ kích thước trong một thanh ghi và không bao giờ đọc nó từ ngăn xếp trong vòng lặp. Do đó, tính toán địa chỉ không thể có trong hỗn hợp, ít nhất là không nằm trong vòng lặp.
gex sát thương

@Gene: Giải thích thú vị thật! Nhưng nó không giải thích các điểm WTF chính: 64 bit đó chậm hơn 32 bit do các quầy hàng đường ống là một chuyện. Nhưng nếu đây là trường hợp, không nên phiên bản 64 bit chậm hơn đáng kể so với phiên bản 32 bit? Thay vào đó, ba trình biên dịch khác nhau phát ra mã chậm ngay cả đối với phiên bản 32 bit khi sử dụng kích thước bộ đệm không đổi thời gian biên dịch; thay đổi kích thước bộ đệm thành tĩnh một lần nữa thay đổi hoàn toàn mọi thứ. Thậm chí còn có một trường hợp trên máy đồng nghiệp của tôi (và trong câu trả lời của Calvin) trong đó phiên bản 64 bit nhanh hơn đáng kể! Nó dường như là hoàn toàn không thể đoán trước ..
gex tự tử

@Mysticial Đó là quan điểm của tôi. Không có sự khác biệt hiệu suất cao nhất khi không có sự tranh chấp nào đối với IU, thời gian xe buýt, v.v. Tham chiếu cho thấy rõ điều đó. Sự tham gia làm cho mọi thứ khác nhau. Dưới đây là một ví dụ từ tài liệu Intel Core: "Một công nghệ mới có trong thiết kế là Macro-Ops Fusion, kết hợp hai hướng dẫn x86 thành một thao tác vi mô duy nhất. Ví dụ, một chuỗi mã phổ biến như so sánh theo sau bước nhảy có điều kiện sẽ trở thành một micro-op duy nhất. Thật không may, công nghệ này không hoạt động ở chế độ 64 bit. " Vì vậy, chúng tôi có tỷ lệ 2: 1 trong tốc độ thực hiện.
Gene

@gex tự tử Tôi thấy những gì bạn nói, nhưng bạn đang suy luận nhiều hơn ý tôi. Tôi đang nói rằng mã chạy nhanh nhất là giữ cho đường ống dẫn và gửi hàng đợi đầy đủ. Tình trạng này rất mong manh. Những thay đổi nhỏ như thêm 32 bit vào tổng luồng dữ liệu và sắp xếp lại lệnh là đủ để phá vỡ nó. Nói tóm lại, OP khẳng định rằng đấu tranh và thử nghiệm là cách duy nhất về phía trước là chính xác.
Gene

10

Tôi đã thử điều này với Visual Studio 2013 Express , sử dụng một con trỏ thay vì chỉ mục, giúp tăng tốc quá trình một chút. Tôi nghi ngờ điều này là do địa chỉ là offset + đăng ký, thay vì offset + đăng ký + (đăng ký << 3). Mã C ++.

   uint64_t* bfrend = buffer+(size/8);
   uint64_t* bfrptr;

// ...

   {
      startP = chrono::system_clock::now();
      count = 0;
      for (unsigned k = 0; k < 10000; k++){
         // Tight unrolled loop with uint64_t
         for (bfrptr = buffer; bfrptr < bfrend;){
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
         }
      }
      endP = chrono::system_clock::now();
      duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "uint64_t\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
           << (10000.0*size)/(duration) << " GB/s" << endl;
   }

mã lắp ráp: r10 = bfrptr, r15 = bfrend, rsi = Count, rdi = buffer, r13 = k:

$LL5@main:
        mov     r10, rdi
        cmp     rdi, r15
        jae     SHORT $LN4@main
        npad    4
$LL2@main:
        mov     rax, QWORD PTR [r10+24]
        mov     rcx, QWORD PTR [r10+16]
        mov     r8, QWORD PTR [r10+8]
        mov     r9, QWORD PTR [r10]
        popcnt  rdx, rax
        popcnt  rax, rcx
        add     rdx, rax
        popcnt  rax, r8
        add     r10, 32
        add     rdx, rax
        popcnt  rax, r9
        add     rsi, rax
        add     rsi, rdx
        cmp     r10, r15
        jb      SHORT $LL2@main
$LN4@main:
        dec     r13
        jne     SHORT $LL5@main

9

Bạn đã thử chuyển qua -funroll-loops -fprefetch-loop-arraysGCC chưa?

Tôi nhận được các kết quả sau với các tối ưu hóa bổ sung sau:

[1829] /tmp/so_25078285 $ cat /proc/cpuinfo |grep CPU|head -n1
model name      : Intel(R) Core(TM) i3-3225 CPU @ 3.30GHz
[1829] /tmp/so_25078285 $ g++ --version|head -n1
g++ (Ubuntu/Linaro 4.7.3-1ubuntu1) 4.7.3

[1829] /tmp/so_25078285 $ g++ -O3 -march=native -std=c++11 test.cpp -o test_o3
[1829] /tmp/so_25078285 $ g++ -O3 -march=native -funroll-loops -fprefetch-loop-arrays -std=c++11     test.cpp -o test_o3_unroll_loops__and__prefetch_loop_arrays

[1829] /tmp/so_25078285 $ ./test_o3 1
unsigned        41959360000     0.595 sec       17.6231 GB/s
uint64_t        41959360000     0.898626 sec    11.6687 GB/s

[1829] /tmp/so_25078285 $ ./test_o3_unroll_loops__and__prefetch_loop_arrays 1
unsigned        41959360000     0.618222 sec    16.9612 GB/s
uint64_t        41959360000     0.407304 sec    25.7443 GB/s

3
Tuy nhiên, kết quả của bạn hoàn toàn lạ (lần đầu tiên không được ký nhanh hơn, sau đó nhanh hơn uint64_t) vì việc hủy đăng ký không khắc phục được vấn đề chính của sự phụ thuộc sai.
gex sát thương

7

Bạn đã thử di chuyển bước giảm bên ngoài vòng lặp chưa? Ngay bây giờ bạn có một sự phụ thuộc dữ liệu thực sự không cần thiết.

Thử:

  uint64_t subset_counts[4] = {};
  for( unsigned k = 0; k < 10000; k++){
     // Tight unrolled loop with unsigned
     unsigned i=0;
     while (i < size/8) {
        subset_counts[0] += _mm_popcnt_u64(buffer[i]);
        subset_counts[1] += _mm_popcnt_u64(buffer[i+1]);
        subset_counts[2] += _mm_popcnt_u64(buffer[i+2]);
        subset_counts[3] += _mm_popcnt_u64(buffer[i+3]);
        i += 4;
     }
  }
  count = subset_counts[0] + subset_counts[1] + subset_counts[2] + subset_counts[3];

Bạn cũng có một số bí danh kỳ lạ đang diễn ra, mà tôi không chắc là tuân thủ các quy tắc răng cưa nghiêm ngặt.


2
Đó là điều đầu tiên tôi làm sau khi đọc câu hỏi. Phá vỡ chuỗi phụ thuộc. Vì hóa ra sự khác biệt về hiệu năng không thay đổi (ít nhất là trên máy tính của tôi - Intel Haswell với GCC 4.7.3).
Nils Pipenbrinck

1
@BenVoigt: Nó phù hợp với răng cưa nghiêm ngặt. void*char*là hai loại có thể được đặt bí danh, vì chúng thực sự được coi là "con trỏ vào một phần của bộ nhớ"! Ý tưởng của bạn liên quan đến việc loại bỏ phụ thuộc dữ liệu là tốt để tối ưu hóa, nhưng nó không trả lời câu hỏi. Và, như @NilsPipenbrinck nói, dường như nó không thay đổi gì cả.
gex tự tử

@gexinf: Quy tắc răng cưa nghiêm ngặt không đối xứng. Bạn có thể sử dụng char*để truy cập a T[]. Bạn không thể sử dụng một cách an toàn T*để truy cập a char[]và mã của bạn dường như làm điều sau.
Ben Voigt

@BenVoigt: Sau đó, bạn không bao giờ có thể hiểu được mallocmột mảng của bất cứ thứ gì, khi malloc trở lại void*và bạn diễn giải nó là T[]. Và tôi khá chắc chắn điều đó void*char*có cùng một ngữ nghĩa liên quan đến răng cưa nghiêm ngặt. Tuy nhiên, tôi đoán điều này khá bất thường ở đây :)
gex tự tử

1
Cá nhân tôi nghĩ rằng cách đúng làuint64_t* buffer = new uint64_t[size/8]; /* type is clearly uint64_t[] */ char* charbuffer=reinterpret_cast<char*>(buffer); /* aliasing a uint64_t[] with char* is safe */
Ben Voigt

6

TL; DR: Sử dụng __builtinnội tại thay thế; họ có thể giúp đỡ

Tôi đã có thể tạo gcc4.8.4 (và thậm chí 4.7.3 trên gcc.godbolt.org) để tạo mã tối ưu cho việc này bằng cách __builtin_popcountllsử dụng cùng một hướng dẫn lắp ráp, nhưng đã may mắn và tình cờ tạo ra mã không có mã bất ngờ phụ thuộc vòng lặp dài do lỗi phụ thuộc sai.

Tôi không chắc chắn 100% về mã điểm chuẩn của mình, nhưng objdumpđầu ra dường như chia sẻ quan điểm của tôi. Tôi sử dụng một số thủ thuật khác ( ++ivs i++) để làm cho trình biên dịch hủy vòng lặp cho tôi mà không có bất kỳ movlhướng dẫn nào (hành vi lạ, tôi phải nói).

Các kết quả:

Count: 20318230000  Elapsed: 0.411156 seconds   Speed: 25.503118 GB/s

Mã điểm chuẩn:

#include <stdint.h>
#include <stddef.h>
#include <time.h>
#include <stdio.h>
#include <stdlib.h>

uint64_t builtin_popcnt(const uint64_t* buf, size_t len){
  uint64_t cnt = 0;
  for(size_t i = 0; i < len; ++i){
    cnt += __builtin_popcountll(buf[i]);
  }
  return cnt;
}

int main(int argc, char** argv){
  if(argc != 2){
    printf("Usage: %s <buffer size in MB>\n", argv[0]);
    return -1;
  }
  uint64_t size = atol(argv[1]) << 20;
  uint64_t* buffer = (uint64_t*)malloc((size/8)*sizeof(*buffer));

  // Spoil copy-on-write memory allocation on *nix
  for (size_t i = 0; i < (size / 8); i++) {
    buffer[i] = random();
  }
  uint64_t count = 0;
  clock_t tic = clock();
  for(size_t i = 0; i < 10000; ++i){
    count += builtin_popcnt(buffer, size/8);
  }
  clock_t toc = clock();
  printf("Count: %lu\tElapsed: %f seconds\tSpeed: %f GB/s\n", count, (double)(toc - tic) / CLOCKS_PER_SEC, ((10000.0*size)/(((double)(toc - tic)*1e+9) / CLOCKS_PER_SEC)));
  return 0;
}

Tùy chọn biên dịch:

gcc --std=gnu99 -mpopcnt -O3 -funroll-loops -march=native bench.c -o bench

Phiên bản GCC:

gcc (Ubuntu 4.8.4-2ubuntu1~14.04.1) 4.8.4

Phiên bản nhân Linux:

3.19.0-58-generic

Thông tin về CPU:

processor   : 0
vendor_id   : GenuineIntel
cpu family  : 6
model       : 70
model name  : Intel(R) Core(TM) i7-4870HQ CPU @ 2.50 GHz
stepping    : 1
microcode   : 0xf
cpu MHz     : 2494.226
cache size  : 6144 KB
physical id : 0
siblings    : 1
core id     : 0
cpu cores   : 1
apicid      : 0
initial apicid  : 0
fpu     : yes
fpu_exception   : yes
cpuid level : 13
wp      : yes
flags       : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx rdtscp lm constant_tsc nopl xtopology nonstop_tsc eagerfpu pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm arat pln pts dtherm fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 invpcid xsaveopt
bugs        :
bogomips    : 4988.45
clflush size    : 64
cache_alignment : 64
address sizes   : 36 bits physical, 48 bits virtual
power management:

3
Thật may mắn khi -funroll-loopstạo ra mã không bị nghẽn cổ chai trên chuỗi phụ thuộc mang theo vòng lặp được tạo bởi popcntdep sai. Sử dụng một phiên bản trình biên dịch cũ mà không biết về sự phụ thuộc sai là một rủi ro. Nếu không -funroll-loops, vòng lặp của gcc 4.8.5 sẽ bị nghẽn cổ chai về độ trễ popcnt thay vì thông lượng, bởi vì nó được tính vàordx . Mã tương tự, được biên dịch bởi gcc 4.9.3 thêm một chuỗi xor edx,edxđể phá vỡ chuỗi phụ thuộc.
Peter Cordes

3
Với các trình biên dịch cũ, mã của bạn sẽ vẫn dễ bị tổn thương với chính xác biến thể hiệu suất giống như OP đã trải qua: những thay đổi có vẻ tầm thường có thể khiến gcc bị chậm do không biết nó sẽ gây ra sự cố. Tìm một cái gì đó xảy ra để làm việc trong một trường hợp trên một trình biên dịch cũ không phải là câu hỏi.
Peter Cordes

2
Đối với bản ghi, x86intrin.hcác _mm_popcnt_*chức năng của GCC là các hàm bao được in nghiêng xung quanh__builtin_popcount* ; nội tuyến phải làm cho cái này chính xác tương đương với cái kia. Tôi rất nghi ngờ bạn sẽ thấy bất kỳ sự khác biệt nào có thể gây ra bằng cách chuyển đổi giữa chúng.
ShadowRanger

-2

Trước hết, hãy thử ước tính hiệu suất cao nhất - kiểm tra https://www.intel.com/content/dam/www/public/us/en/document/manuals/64-ia-32-architectures-optimization-manual.pdf , đặc biệt, Phụ lục C.

Trong trường hợp của bạn, đó là bảng C-10 hiển thị lệnh POPCNT có độ trễ = 3 đồng hồ và thông lượng = 1 đồng hồ. Thông lượng cho thấy tốc độ tối đa của bạn trong đồng hồ (nhân với tần số lõi và 8 byte trong trường hợp popcnt64 để có được số băng thông tốt nhất có thể của bạn).

Bây giờ kiểm tra trình biên dịch đã làm gì và tổng hợp thông lượng của tất cả các hướng dẫn khác trong vòng lặp. Điều này sẽ đưa ra ước tính tốt nhất có thể cho mã được tạo.

Cuối cùng, hãy xem xét các phụ thuộc dữ liệu giữa các hướng dẫn trong vòng lặp vì chúng sẽ buộc độ trễ lớn thay vì thông lượng - vì vậy hãy chia các hướng dẫn lặp đơn trên chuỗi lưu lượng dữ liệu và tính độ trễ qua chúng một cách ngây thơ nhận tối đa từ chúng. nó sẽ đưa ra ước tính sơ bộ có tính đến phụ thuộc luồng dữ liệu.

Tuy nhiên, trong trường hợp của bạn, chỉ cần viết mã đúng cách sẽ loại bỏ tất cả những sự phức tạp này. Thay vì tích lũy vào cùng một biến đếm, chỉ cần tích lũy vào các biến khác nhau (như Count0, Count1, ... Count8) và tổng hợp chúng ở cuối. Hoặc thậm chí tạo ra một mảng các số đếm [8] và tích lũy cho các phần tử của nó - có lẽ, nó sẽ được vector hóa ngay cả và bạn sẽ có được thông lượng tốt hơn nhiều.

PS và không bao giờ chạy điểm chuẩn trong một giây, đầu tiên làm nóng lõi sau đó chạy vòng trong ít nhất 10 giây hoặc tốt hơn 100 giây. nếu không, bạn sẽ kiểm tra phần mềm quản lý nguồn và triển khai DVFS trong phần cứng :)

PPS Tôi đã nghe những cuộc tranh luận bất tận về việc thời gian chuẩn nên chạy. Hầu hết những người thông minh nhất thậm chí còn hỏi tại sao 10 giây chứ không phải 11 hay 12. Tôi nên thừa nhận điều này thật buồn cười trên lý thuyết. Trong thực tế, bạn chỉ cần đi và chạy điểm chuẩn hàng trăm lần liên tiếp và ghi lại độ lệch. Đó buồn cười. Hầu hết mọi người thay đổi nguồn và chạy băng ghế sau đó chính xác ONCE để ghi lại hiệu suất mới. Làm đúng việc đúng.

Vẫn chưa thuyết phục? Chỉ cần sử dụng phiên bản chuẩn C trên của assp1r1n3 ( https://stackoverflow.com/a/37026212/9706746 ) và thử 100 thay vì 10000 trong vòng thử lại.

Chương trình 7960X của tôi, với RETRY = 100:

Đếm: 203182300 Đã qua: 0,008385 giây Tốc độ: 12.505379 GB / s

Đếm: 203182300 Đã qua: 0,011063 giây Tốc độ: 9,478225 GB / s

Đếm: 203182300 Đã qua: 0,011188 giây Tốc độ: 9.372327 GB / s

Đếm: 203182300 Đã qua: 0,010393 giây Tốc độ: 10.089252 GB / s

Đếm: 203182300 Đã qua: 0,009076 giây Tốc độ: 11,553283 GB / s

với RETRY = 10000:

Đếm: 20318230000 Đã qua: 0,661791 giây Tốc độ: 15,844519 GB / s

Đếm: 20318230000 Đã qua: 0,665422 giây Tốc độ: 15,758060 GB / s

Đếm: 20318230000 Đã qua: 0,660983 giây Tốc độ: 15,863888 GB / s

Đếm: 20318230000 Đã qua: 0,665337 giây Tốc độ: 15.760073 GB / s

Đếm: 20318230000 Đã qua: 0,662138 giây Tốc độ: 15.836215 GB / s

PPPS Cuối cùng, về "câu trả lời được chấp nhận" và những điều sai lầm khác ;-)

Hãy sử dụng câu trả lời của assp1r1n3 - anh ta có lõi 2,5Ghz. POPCNT có 1 đồng hồ throuhgput, mã của anh ấy đang sử dụng popcnt 64 bit. Vì vậy, toán học là 2,5Ghz * 1 xung nhịp * 8 byte = 20 GB / s cho thiết lập của anh ta. Anh ta đang nhìn thấy 25Gb / s, có lẽ do turbo boost lên khoảng 3Ghz.

Do đó, hãy truy cập ark.intel.com và tìm i7-4870HQ: https://ark.intel.com/products/83504/Intel-Core-i7-4870HQ-Processor-6M-Cache-up-to-3-70 -GHz-? Q = i7-4870HQ

Lõi đó có thể chạy tới 3,7Ghz và tốc độ tối đa thực sự là 29,6 GB / giây cho phần cứng của anh ta. Vậy đâu là 4GB / s? Có lẽ, nó đã dành cho logic vòng lặp và mã xung quanh khác trong mỗi lần lặp.

Bây giờ ở đâu là phụ thuộc sai này? phần cứng chạy ở tốc độ gần như cao điểm. Có lẽ toán học của tôi rất tệ, đôi khi nó xảy ra :)

PPPPPS Vẫn có người cho rằng HW errata là thủ phạm, vì vậy tôi làm theo gợi ý và tạo ví dụ asm nội tuyến, xem bên dưới.

Trên 7960X của tôi, phiên bản đầu tiên (có đầu ra duy nhất đến cnt0) chạy ở tốc độ 11MB / s, phiên bản thứ hai (với đầu ra là cnt0, cnt1, cnt2 và cnt3) chạy ở tốc độ 33MB / s. Và người ta có thể nói - thì đấy! đó là sự phụ thuộc đầu ra.

OK, có thể, điểm tôi đưa ra là không có ý nghĩa gì khi viết mã như thế này và nó không phải là vấn đề phụ thuộc đầu ra mà là tạo mã câm. Chúng tôi không kiểm tra phần cứng, chúng tôi đang viết mã để giải phóng hiệu suất tối đa. Bạn có thể mong đợi rằng HW OOO nên đổi tên và che giấu những "phụ thuộc đầu ra" đó, nhưng, chỉ cần làm đúng những điều đúng và bạn sẽ không bao giờ phải đối mặt với bất kỳ bí ẩn nào.

uint64_t builtin_popcnt1a(const uint64_t* buf, size_t len) 
{
    uint64_t cnt0, cnt1, cnt2, cnt3;
    cnt0 = cnt1 = cnt2 = cnt3 = 0;
    uint64_t val = buf[0];
    #if 0
        __asm__ __volatile__ (
            "1:\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "popcnt %2, %1\n\t"
            "subq $4, %0\n\t"
            "jnz 1b\n\t"
        : "+q" (len), "=q" (cnt0)
        : "q" (val)
        :
        );
    #else
        __asm__ __volatile__ (
            "1:\n\t"
            "popcnt %5, %1\n\t"
            "popcnt %5, %2\n\t"
            "popcnt %5, %3\n\t"
            "popcnt %5, %4\n\t"
            "subq $4, %0\n\t"
            "jnz 1b\n\t"
        : "+q" (len), "=q" (cnt0), "=q" (cnt1), "=q" (cnt2), "=q" (cnt3)
        : "q" (val)
        :
        );
    #endif
    return cnt0;
}

Nếu bạn định thời gian trong các chu kỳ xung nhịp lõi (thay vì giây), thì 1 giây là rất nhiều thời gian cho một vòng lặp giới hạn CPU nhỏ. Thậm chí 100ms là tốt để tìm sự khác biệt lớn hoặc kiểm tra bộ đếm hoàn hảo cho số lượng uop. Đặc biệt trên Skylake, nơi quản lý trạng thái P phần cứng cho phép nó tăng tốc độ xung nhịp tối đa tính bằng micrô giây sau khi tải bắt đầu.
Peter Cordes

clang có thể tự động vector hóa __builtin_popcountlvới AVX2 vpshufbvà không cần nhiều bộ tích lũy trong nguồn C để làm như vậy. Tôi không chắc về _mm_popcnt_u64; mà chỉ có thể tự động vector hóa với AVX512-VPOPCNT. (Xem Đếm 1 bit (số lượng dân số) trên dữ liệu lớn bằng AVX-512 hoặc AVX-2 /)
Peter Cordes

Nhưng dù sao, nhìn vào hướng dẫn tối ưu hóa của Intel sẽ không giúp ích gì: vì câu trả lời được chấp nhận cho thấy, vấn đề là sự phụ thuộc đầu ra không mong muốn popcnt. Điều này được ghi lại trong errata của Intel đối với một số cấu trúc vi mô gần đây của họ, nhưng tôi nghĩ không phải lúc đó. Phân tích chuỗi dep của bạn sẽ thất bại nếu có sự phụ thuộc sai bất ngờ, vì vậy câu trả lời này là lời khuyên chung chung nhưng không áp dụng ở đây.
Peter Cordes

1
Bạn đang đùa tôi à Tôi không phải "tin" vào những thứ tôi có thể đo lường bằng thực nghiệm với các bộ đếm hiệu suất trong một vòng lặp asm viết tay. Chúng chỉ là sự thật. Tôi đã thử nghiệm và Skylake đã sửa lỗi phụ thuộc sai cho lzcnt/ tzcnt, nhưng không cho popcnt. Xem lỗi SKL029 của Intel trong intel.com/content/dam/www/public/us/en/document/ ,. Ngoài ra, gcc.gnu.org/ormszilla/show_orms.cgi?id=62011 được "giải quyết cố định", không "không hợp lệ". Không có cơ sở cho khiếu nại của bạn rằng không có sự phụ thuộc đầu ra trong CTNH.
Peter Cordes

1
Nếu bạn thực hiện một vòng lặp đơn giản như popcnt eax, edx/ dec ecx / jnz, bạn sẽ mong đợi nó chạy ở mức 1 trên mỗi đồng hồ, bị tắc nghẽn về thông lượng popcnt và thông lượng chi nhánh. Nhưng nó thực sự chỉ chạy ở mức 1 trên 3 đồng hồ bị tắc nghẽn về popcntđộ trễ khi ghi đè liên tục EAX, mặc dù bạn mong đợi nó chỉ ở chế độ ghi. Bạn có một Skylake, vì vậy bạn có thể tự mình thử nó.
Peter Cordes

-3

Ok, tôi muốn cung cấp một câu trả lời nhỏ cho một trong những câu hỏi phụ mà OP đã hỏi mà dường như không được giải quyết trong các câu hỏi hiện có. Hãy cẩn thận, tôi chưa thực hiện bất kỳ thử nghiệm hoặc tạo mã, hoặc tháo gỡ nào, chỉ muốn chia sẻ một suy nghĩ cho những người khác có thể giải thích.

Tại sao sự staticthay đổi hiệu suất?

Dòng trong câu hỏi: uint64_t size = atol(argv[1])<<20;

Câu trả lời ngắn

Tôi sẽ xem xét hội đồng được tạo để truy cập sizevà xem liệu có thêm các bước bổ sung con trỏ liên quan đến phiên bản không tĩnh.

Câu trả lời dài

Vì chỉ có một bản sao của biến đó có được khai báo statichay không và kích thước không thay đổi, tôi đưa ra giả thuyết rằng sự khác biệt là vị trí của bộ nhớ được sử dụng để sao lưu biến cùng với nơi nó được sử dụng trong mã hơn nữa xuống.

Ok, để bắt đầu với điều hiển nhiên, hãy nhớ rằng tất cả các biến cục bộ (cùng với các tham số) của hàm được cung cấp không gian trên ngăn xếp để sử dụng làm bộ lưu trữ. Bây giờ, rõ ràng, khung stack cho main () không bao giờ dọn sạch và chỉ được tạo một lần. Ok, những gì về làm cho nó static? Chà, trong trường hợp đó, trình biên dịch biết dự trữ không gian trong không gian dữ liệu toàn cầu của quy trình để không thể xóa vị trí bằng cách loại bỏ khung stack. Nhưng vẫn vậy, chúng ta chỉ có một địa điểm vậy sự khác biệt là gì? Tôi nghi ngờ nó có liên quan đến cách các vị trí bộ nhớ trên ngăn xếp được tham chiếu.

Khi trình biên dịch tạo bảng ký hiệu, nó chỉ tạo một mục nhập cho nhãn cùng với các thuộc tính có liên quan, như kích thước, v.v. Nó biết rằng nó phải dành không gian thích hợp trong bộ nhớ nhưng thực tế không chọn vị trí đó cho đến khi nào đó về sau quá trình sau khi làm phân tích sinh động và có thể đăng ký phân bổ. Làm thế nào sau đó trình liên kết biết địa chỉ nào sẽ cung cấp cho mã máy cho mã lắp ráp cuối cùng? Nó hoặc biết vị trí cuối cùng hoặc biết làm thế nào để đến địa điểm. Với một ngăn xếp, khá đơn giản để tham chiếu đến một vị trí dựa trên một hai yếu tố, con trỏ tới stackframe và sau đó là phần bù vào khung. Điều này về cơ bản là vì trình liên kết không thể biết vị trí của stackframe trước khi chạy.


2
Đối với tôi, dường như nhiều khả năng việc sử dụng staticđã thay đổi cấp phát thanh ghi cho chức năng theo cách ảnh hưởng đến sự phụ thuộc đầu ra sai của popcntCPU Intel mà OP đang thử nghiệm, với trình biên dịch không biết để tránh chúng. (Vì ổ gà hiệu năng này trong CPU Intel chưa được phát hiện.) Trình biên dịch có thể giữ một staticbiến cục bộ trong một thanh ghi, giống như một biến lưu trữ tự động, nhưng nếu chúng không tối ưu hóa giả sử mainchỉ chạy một lần, thì nó sẽ ảnh hưởng code-gen (vì giá trị được đặt chỉ bằng cuộc gọi đầu tiên.)
Peter Cordes

1
Dù sao, sự khác biệt hiệu suất giữa [RIP + rel32][rsp + 42]chế độ địa chỉ là không đáng kể đối với hầu hết các trường hợp. cmp dword [RIP+rel32], immediatekhông thể kết hợp vi mô vào một tải + cmp uop, nhưng tôi không nghĩ đó sẽ là một yếu tố. Như tôi đã nói, bên trong các vòng lặp có thể vẫn ở trong một thanh ghi, nhưng điều chỉnh C ++ có thể có nghĩa là các lựa chọn trình biên dịch khác nhau.
Peter Cordes
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.