Tôi đang tìm cách nhanh nhất cho popcount
cá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_t
là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à x
megabyte 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 popcount
nộ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_t
phiên bản chỉ bằng một nửa so với unsigned
phiê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 static
trướ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, u64
phiên bản thậm chí còn nhanh hơn u32
phiê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
u32
vàu64
? - 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
static
từ khóa làm chou64
vò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 static
từ 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ị.