Làm thế nào có thể đạt được hiệu suất cao nhất về mặt lý thuyết của 4 thao tác điểm nổi (độ chính xác kép) trên mỗi chu kỳ trên CPU Intel x86-64 hiện đại?
Theo tôi hiểu, phải mất ba chu kỳ cho một SSE add và năm chu kỳ mulđể hoàn thành trên hầu hết các CPU Intel hiện đại (xem ví dụ 'Bảng hướng dẫn' của Agner Fog ). Do đường ống, người ta có thể nhận được thông lượng một addlần trong mỗi chu kỳ nếu thuật toán có ít nhất ba phép tính tổng độc lập. Vì điều đó đúng với addpdcác addsdphiên bản đóng gói cũng như các thanh ghi vô hướng và các thanh ghi SSE có thể chứa hai doublethông lượng có thể bằng hai flop mỗi chu kỳ.
Hơn nữa, dường như (mặc dù tôi chưa thấy bất kỳ tài liệu phù hợp nào về vấn đề này) addvà mulcó thể được thực thi song song với thông lượng tối đa về mặt lý thuyết là bốn flops mỗi chu kỳ.
Tuy nhiên, tôi không thể sao chép hiệu suất đó bằng chương trình C / C ++ đơn giản. Nỗ lực tốt nhất của tôi dẫn đến khoảng 2,7 flops / chu kỳ. Nếu bất cứ ai cũng có thể đóng góp một chương trình C / C ++ hoặc trình biên dịch chương trình biên dịch đơn giản thể hiện hiệu suất cao nhất sẽ được đánh giá cao.
Nỗ lực của tôi:
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <sys/time.h>
double stoptime(void) {
struct timeval t;
gettimeofday(&t,NULL);
return (double) t.tv_sec + t.tv_usec/1000000.0;
}
double addmul(double add, double mul, int ops){
// Need to initialise differently otherwise compiler might optimise away
double sum1=0.1, sum2=-0.1, sum3=0.2, sum4=-0.2, sum5=0.0;
double mul1=1.0, mul2= 1.1, mul3=1.2, mul4= 1.3, mul5=1.4;
int loops=ops/10; // We have 10 floating point operations inside the loop
double expected = 5.0*add*loops + (sum1+sum2+sum3+sum4+sum5)
+ pow(mul,loops)*(mul1+mul2+mul3+mul4+mul5);
for (int i=0; i<loops; i++) {
mul1*=mul; mul2*=mul; mul3*=mul; mul4*=mul; mul5*=mul;
sum1+=add; sum2+=add; sum3+=add; sum4+=add; sum5+=add;
}
return sum1+sum2+sum3+sum4+sum5+mul1+mul2+mul3+mul4+mul5 - expected;
}
int main(int argc, char** argv) {
if (argc != 2) {
printf("usage: %s <num>\n", argv[0]);
printf("number of operations: <num> millions\n");
exit(EXIT_FAILURE);
}
int n = atoi(argv[1]) * 1000000;
if (n<=0)
n=1000;
double x = M_PI;
double y = 1.0 + 1e-8;
double t = stoptime();
x = addmul(x, y, n);
t = stoptime() - t;
printf("addmul:\t %.3f s, %.3f Gflops, res=%f\n", t, (double)n/t/1e9, x);
return EXIT_SUCCESS;
}
Tổng hợp với
g++ -O2 -march=native addmul.cpp ; ./a.out 1000
tạo ra đầu ra sau trên Intel Core i5-750, 2,66 GHz.
addmul: 0.270 s, 3.707 Gflops, res=1.326463
Đó là, chỉ khoảng 1,4 flops mỗi chu kỳ. Nhìn vào mã trình biên dịch với
g++ -S -O2 -march=native -masm=intel addmul.cppvòng lặp chính có vẻ tối ưu với tôi:
.L4:
inc eax
mulsd xmm8, xmm3
mulsd xmm7, xmm3
mulsd xmm6, xmm3
mulsd xmm5, xmm3
mulsd xmm1, xmm3
addsd xmm13, xmm2
addsd xmm12, xmm2
addsd xmm11, xmm2
addsd xmm10, xmm2
addsd xmm9, xmm2
cmp eax, ebx
jne .L4
Thay đổi các phiên bản vô hướng với các phiên bản đóng gói ( addpdvà mulpd) sẽ tăng gấp đôi số lượng flop mà không thay đổi thời gian thực hiện và vì vậy tôi chỉ nhận được 2,8 flop mỗi chu kỳ. Có một ví dụ đơn giản mà đạt được bốn flops mỗi chu kỳ?
Chương trình nhỏ xinh của Mysticial; đây là kết quả của tôi (chỉ chạy trong vài giây):
gcc -O2 -march=nocona: 5,6 Gflops trong số 10,66 Gflops (2,1 flops / chu kỳ)cl /O2, openmp đã bị xóa: 10.1 Gflops trong số 10,66 Gflops (3,8 flops / chu kỳ)
Tất cả có vẻ hơi phức tạp, nhưng kết luận của tôi cho đến nay:
gcc -O2thay đổi thứ tự của các phép toán dấu phẩy động độc lập với mục đích xen kẽaddpdvàmulpdnếu có thể. Áp dụng tương tự chogcc-4.6.2 -O2 -march=core2.gcc -O2 -march=noconadường như giữ nguyên thứ tự của các phép toán dấu phẩy động như được định nghĩa trong nguồn C ++.cl /O2, trình biên dịch 64 bit từ SDK cho Windows 7 sẽ tự động hủy vòng lặp và dường như thử và sắp xếp các hoạt động sao cho các nhóm baaddpdthay thế với bamulpd(ít nhất là trên hệ thống của tôi và cho chương trình đơn giản của tôi) .My Core i5 750 ( kiến trúc Nehalem ) không thích xen kẽ các add và mul và dường như không thể chạy song song cả hai hoạt động. Tuy nhiên, nếu được nhóm thành 3 thì nó đột nhiên hoạt động như ma thuật.
Các kiến trúc khác (có thể là Sandy Bridge và các công trình khác) dường như có thể thực thi song song add / mul mà không gặp vấn đề gì nếu chúng thay thế trong mã lắp ráp.
Mặc dù khó thừa nhận, nhưng trên hệ thống của tôi
cl /O2thực hiện công việc tốt hơn nhiều ở các hoạt động tối ưu hóa ở mức độ thấp cho hệ thống của tôi và đạt được hiệu suất cao nhất cho ví dụ C ++ nhỏ ở trên. Tôi đo được giữa 1,85-2,01 flops / chu kỳ (đã sử dụng đồng hồ () trong Windows không chính xác. Tôi đoán, cần sử dụng bộ hẹn giờ tốt hơn - cảm ơn Mackie Messer).Cách tốt nhất tôi quản lý
gcclà lặp lại thủ công hủy đăng ký và sắp xếp các phép cộng và phép nhân trong các nhóm ba. Vớig++ -O2 -march=nocona addmul_unroll.cpptôi nhận được tốt nhất0.207s, 4.825 Gflopstương ứng với 1,8 flops / chu kỳ mà bây giờ tôi khá hài lòng.
Trong mã C ++, tôi đã thay thế forvòng lặp bằng
for (int i=0; i<loops/3; i++) {
mul1*=mul; mul2*=mul; mul3*=mul;
sum1+=add; sum2+=add; sum3+=add;
mul4*=mul; mul5*=mul; mul1*=mul;
sum4+=add; sum5+=add; sum1+=add;
mul2*=mul; mul3*=mul; mul4*=mul;
sum2+=add; sum3+=add; sum4+=add;
mul5*=mul; mul1*=mul; mul2*=mul;
sum5+=add; sum1+=add; sum2+=add;
mul3*=mul; mul4*=mul; mul5*=mul;
sum3+=add; sum4+=add; sum5+=add;
}
Và lắp ráp bây giờ trông giống như
.L4:
mulsd xmm8, xmm3
mulsd xmm7, xmm3
mulsd xmm6, xmm3
addsd xmm13, xmm2
addsd xmm12, xmm2
addsd xmm11, xmm2
mulsd xmm5, xmm3
mulsd xmm1, xmm3
mulsd xmm8, xmm3
addsd xmm10, xmm2
addsd xmm9, xmm2
addsd xmm13, xmm2
...
-funroll-loops). Đã thử với gcc phiên bản 4.4.1 và 4.6.2, nhưng đầu ra asm có vẻ ổn?
-O3cho gcc, cho phép -ftree-vectorize? Có lẽ kết hợp với -funroll-loopsmặc dù tôi không nếu điều đó thực sự cần thiết. Sau đó, việc so sánh có vẻ không công bằng nếu một trong các trình biên dịch thực hiện vector hóa / không kiểm soát, trong khi cái kia không phải vì nó không thể, nhưng vì nó không được nói quá.
-funroll-loopscó lẽ là một cái gì đó để thử. Nhưng tôi nghĩ -ftree-vectorizelà bên cạnh quan điểm. OP đang cố gắng duy trì 1 mul + 1 thêm hướng dẫn / chu kỳ. Các hướng dẫn có thể là vô hướng hoặc vectơ - không thành vấn đề vì độ trễ và thông lượng là như nhau. Vì vậy, nếu bạn có thể duy trì 2 / chu kỳ với SSE vô hướng, thì bạn có thể thay thế chúng bằng vector SSE và bạn sẽ đạt được 4 flops / chu kỳ. Trong câu trả lời của tôi, tôi đã làm điều đó từ SSE -> AVX. Tôi đã thay thế tất cả SSE bằng AVX - cùng độ trễ, cùng thông lượng, gấp đôi flops.