Bagaimana kinerja puncak teoritis dari 4 operasi floating point (presisi ganda) per siklus dapat dicapai pada CPU Intel x86-64 modern?
Sejauh yang saya mengerti, dibutuhkan tiga siklus untuk SSE add
dan lima siklus untuk mul
menyelesaikan sebagian besar CPU Intel modern (lihat misalnya 'Instruction Tables' Agner Fog ). Karena pipelining, seseorang bisa mendapatkan throughput satu add
per siklus jika algoritma memiliki setidaknya tiga penjumlahan independen. Karena itu berlaku untuk paket addpd
serta addsd
versi skalar dan register SSE dapat berisi duadouble
, throughputnya dapat sebanyak dua jepit per siklus.
Selain itu, tampaknya (walaupun saya belum melihat dokumentasi yang tepat tentang ini) add
dan mul
dapat dieksekusi secara paralel memberikan throughput maks teoretis empat jepit per siklus.
Namun, saya belum bisa meniru kinerja itu dengan program C / C ++ sederhana. Upaya terbaik saya menghasilkan sekitar 2,7 jepit / siklus. Kalau ada yang bisa berkontribusi C / C ++ atau program assembler sederhana yang menunjukkan kinerja puncak yang akan sangat dihargai.
Usaha saya:
#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;
}
Disusun dengan
g++ -O2 -march=native addmul.cpp ; ./a.out 1000
menghasilkan output berikut pada Intel Core i5-750, 2,66 GHz.
addmul: 0.270 s, 3.707 Gflops, res=1.326463
Artinya, hanya sekitar 1,4 jepit per siklus. Melihat kode assembler dengan
g++ -S -O2 -march=native -masm=intel addmul.cpp
loop utama tampaknya agak optimal bagi saya:
.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
Mengubah versi skalar dengan versi paket (addpd
dan mulpd
) akan menggandakan jumlah kegagalan tanpa mengubah waktu eksekusi dan jadi saya akan mendapatkan 2,8 flop per siklus. Apakah ada contoh sederhana yang mencapai empat jepit per siklus?
Program kecil yang bagus oleh Mysticial; inilah hasil saya (jalankan hanya untuk beberapa detik saja):
gcc -O2 -march=nocona
: 5.6 Gflops dari 10.66 Gflops (2.1 flops / cycle)cl /O2
, openmp dihapus: 10.1 Gflops dari 10.66 Gflops (3.8 flops / cycle)
Itu semua tampaknya agak rumit, tetapi kesimpulan saya sejauh ini:
gcc -O2
mengubah urutan operasi floating point independen dengan tujuan bergantianaddpd
danmulpd
jika memungkinkan. Hal yang sama berlaku untukgcc-4.6.2 -O2 -march=core2
.gcc -O2 -march=nocona
tampaknya menjaga urutan operasi floating point sebagaimana didefinisikan dalam sumber C ++.cl /O2
, kompiler 64-bit dari SDK untuk Windows 7 melakukan loop-unrolling secara otomatis dan tampaknya mencoba dan mengatur operasi sehingga kelompok-kelompokaddpd
bergantian tiga dengan tigamulpd
(baik, setidaknya pada sistem saya dan untuk program sederhana saya) .Saya Core i5 750 ( Nehalem arsitektur ) tidak seperti bolak add dan ini mul dan tampaknya tidak dapat menjalankan kedua operasi secara paralel. Namun, jika dikelompokkan dalam 3 itu tiba-tiba berfungsi seperti sihir.
Arsitektur lain (mungkin Sandy Bridge dan lainnya) tampaknya mampu mengeksekusi add / mul secara paralel tanpa masalah jika mereka berganti dalam kode assembly.
Meskipun sulit untuk diakui, tetapi pada sistem saya
cl /O2
melakukan pekerjaan yang jauh lebih baik pada operasi optimalisasi tingkat rendah untuk sistem saya dan mencapai kinerja puncak mendekati untuk contoh C ++ kecil di atas. Saya mengukur antara 1,85-2,01 jepit / siklus (telah menggunakan jam () pada Windows yang tidak tepat. Saya kira, perlu menggunakan timer yang lebih baik - terima kasih Mackie Messer).Yang terbaik yang saya kelola
gcc
adalah secara manual membuka gulungan dan mengatur penambahan dan perkalian dalam kelompok tiga. Dengang++ -O2 -march=nocona addmul_unroll.cpp
saya mendapatkan yang terbaik0.207s, 4.825 Gflops
yang sesuai dengan 1,8 jepit / siklus yang saya cukup senang dengan sekarang.
Dalam kode C ++ saya telah mengganti for
loop dengan
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;
}
Dan perakitan sekarang terlihat seperti
.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
...
sumber
-funroll-loops
). Sudah mencoba dengan versi gcc 4.4.1 dan 4.6.2, tetapi output asm terlihat ok?-O3
untuk gcc, yang memungkinkan-ftree-vectorize
? Mungkin dikombinasikan dengan-funroll-loops
meskipun saya tidak tidak jika itu benar-benar diperlukan. Setelah semua perbandingan memang tampak tidak adil jika salah satu kompiler melakukan vektorisasi / membuka gulungan, sementara yang lain tidak karena itu tidak bisa, tetapi karena itu diberitahu tidak juga.-funroll-loops
mungkin sesuatu untuk dicoba. Tapi saya pikir-ftree-vectorize
itu intinya. OP sedang mencoba hanya untuk mempertahankan 1 mul + 1 menambahkan instruksi / siklus. Instruksi dapat berupa skalar atau vektor - tidak masalah karena latensi dan throughputnya sama. Jadi jika Anda dapat mempertahankan 2 / siklus dengan skalar SSE, maka Anda dapat menggantinya dengan vektor SSE dan Anda akan mencapai 4 jepit / siklus. Dalam jawaban saya, saya melakukan hal itu dari SSE -> AVX. Saya mengganti semua SSE dengan AVX - latensi yang sama, throughput yang sama, 2x jepit.Jawaban:
Saya telah melakukan tugas yang tepat ini sebelumnya. Tapi itu terutama untuk mengukur konsumsi daya dan suhu CPU. Kode berikut (yang cukup panjang) mencapai hampir optimal pada Core i7 2600K saya.
Hal utama yang perlu diperhatikan di sini adalah banyaknya loop-unrolling manual serta interleaving dari multiplies dan menambahkan ...
Proyek lengkap dapat ditemukan di GitHub saya: https://github.com/Mysticial/Flops
Peringatan:
Jika Anda memutuskan untuk mengompilasi dan menjalankan ini, perhatikan suhu CPU Anda !!!
Pastikan Anda tidak kepanasan. Dan pastikan pelambatan CPU tidak memengaruhi hasil Anda!
Selain itu, saya tidak bertanggung jawab atas kerusakan apa pun yang mungkin terjadi akibat menjalankan kode ini.
Catatan:
ICC 11 (Intel Compiler 11) secara mengejutkan mengalami kesulitan menyusunnya dengan baik.
Output (1 utas, 10000000 iterations) - Dikompilasi dengan Visual Studio 2010 SP1 - x64 Rilis:
Mesin tersebut adalah Core i7 2600K @ 4.4 GHz. Puncak SSE teoretis adalah 4 jepit * 4,4 GHz = 17,6 GFlops . Kode ini mencapai 17,3 GFlops - tidak buruk.
Keluaran (8 utas, 10.000.000 iterasi) - Dikompilasi dengan Visual Studio 2010 SP1 - x64 Rilis:
Puncak SSE teoretis adalah 4 jepit * 4 core * 4,4 GHz = 70,4 GFlops. Sebenarnya adalah 65,5 GFlops .
Mari kita selangkah lebih maju. AVX ...
Output (1 utas, 10000000 iterations) - Dikompilasi dengan Visual Studio 2010 SP1 - x64 Rilis:
Puncak AVX teoritis adalah 8 jepit * 4,4 GHz = 35,2 GFlops . Sebenarnya adalah 33,4 GFlops .
Keluaran (8 utas, 10.000.000 iterasi) - Dikompilasi dengan Visual Studio 2010 SP1 - x64 Rilis:
Puncak AVX teoritis adalah 8 jepit * 4 core * 4,4 GHz = 140,8 GFlops. Sebenarnya adalah 138,2 GFlops .
Sekarang untuk beberapa penjelasan:
Bagian kritis kinerja jelas adalah 48 instruksi di dalam lingkaran dalam. Anda akan melihat bahwa itu dibagi menjadi 4 blok yang terdiri dari 12 instruksi masing-masing. Masing-masing dari 12 blok instruksi ini sepenuhnya independen satu sama lain - dan mengambil rata-rata 6 siklus untuk dieksekusi.
Jadi ada 12 instruksi dan 6 siklus antara masalah-untuk-digunakan. Latensi multiplikasi adalah 5 siklus, jadi cukup untuk menghindari warung latensi.
Langkah normalisasi diperlukan untuk menjaga data dari over / underflow. Ini diperlukan karena kode tidak melakukan apa pun akan secara perlahan meningkatkan / mengurangi besarnya data.
Jadi sebenarnya mungkin untuk melakukan yang lebih baik dari ini jika Anda hanya menggunakan semua nol dan menyingkirkan langkah normalisasi. Namun, karena saya menulis patokan untuk mengukur konsumsi daya dan suhu, saya harus memastikan jepit berada pada data "nyata", bukan nol - karena unit eksekusi mungkin memiliki penanganan kasus khusus untuk nol yang menggunakan lebih sedikit daya dan menghasilkan lebih sedikit panas.
Hasil lebih:
Thread: 1
Puncak SSE Teoretis: 4 jepit * 3,5 GHz = 14,0 GFlops . Sebenarnya adalah 13,3 GFlops .
Thread: 8
Puncak SSE Teoretis: 4 jepit * 4 core * 3,5 GHz = 56,0 GFlops . Sebenarnya adalah 51,3 GFlops .
Prosesor saya mencapai 76C saat proses multi-ulir! Jika Anda menjalankan ini, pastikan hasilnya tidak terpengaruh oleh pelambatan CPU.
Thread: 1
Puncak SSE Teoretis: 4 jepit * 3,2 GHz = 12,8 GFlops . Sebenarnya adalah 12,3 GFlops .
Thread: 8
Puncak SSE Teoretis: 4 jepit * 8 core * 3,2 GHz = 102,4 GFlops . Sebenarnya adalah 97,9 GFlops .
sumber
1.814s, 5.292 Gflops, sum=0.448883
dari puncak 10,68 Gflop atau hanya kurang dari 2,0 jepit per siklus. Tampaknyaadd
/mul
tidak dieksekusi secara paralel. Ketika saya mengubah kode Anda dan selalu menambah / mengalikan dengan register yang sama, katakanrC
, tiba-tiba mencapai puncaknya:0.953s, 10.068 Gflops, sum=0
atau 3,8 flop / cycle. Sangat aneh.cl /O2
(64-bit dari windows SDK) dan bahkan contoh saya berjalan mendekati puncak untuk operasi skalar (1,9 gagal / siklus) di sana. Compiler loop-unrolls dan reorder tetapi itu mungkin bukan alasan untuk melihat lebih dalam lagi. Mencekik bukan masalah Saya baik-baik saja pada cpu saya dan menjaga iterasi pada 100k. :)Ada satu titik dalam arsitektur Intel yang orang sering lupa, port pengiriman dibagi antara Int dan FP / SIMD. Ini berarti bahwa Anda hanya akan mendapatkan jumlah tertentu dari FP / SIMD sebelum logika loop akan membuat gelembung di aliran titik mengambang Anda. Mystical mendapat lebih banyak jepit dari kodenya, karena dia menggunakan langkah yang lebih panjang dalam loop yang tidak terbuka.
Jika Anda melihat arsitektur Nehalem / Sandy Bridge di sini http://www.realworldtech.com/page.cfm?ArticleID=RWT091810191937&p=6 cukup jelas apa yang terjadi.
Sebaliknya, akan lebih mudah untuk mencapai kinerja puncak pada AMD (Bulldozer) karena pipa INT dan FP / SIMD memiliki port masalah terpisah dengan penjadwal mereka sendiri.
Ini hanya teoretis karena saya tidak memiliki satupun dari prosesor ini untuk diuji.
sumber
inc
,cmp
, danjl
. Semua ini dapat pergi ke port # 5 dan tidak mengganggu dengan vektorfadd
ataufmul
. Saya lebih suka curiga bahwa decoder (kadang-kadang) masuk ke jalan. Perlu mempertahankan antara dua dan tiga instruksi per siklus. Saya tidak ingat batasan yang pasti, tetapi panjang instruksi, awalan, dan penyelarasan ikut berperan.cmp
danjl
tentu saja pergi ke port 5,inc
tidak begitu yakin karena selalu berkelompok dengan 2 lainnya. Tapi Anda benar, sulit untuk mengatakan di mana kemacetan dan decoder juga bisa menjadi bagian dari itu.Cabang pasti dapat mencegah Anda mempertahankan kinerja teoretis puncak. Apakah Anda melihat perbedaan jika Anda melakukan loop-unrolling secara manual? Misalnya, jika Anda menempatkan 5 atau 10 kali lebih banyak ops per loop iterasi:
sumber
-funroll-loops
opsi yang bahkan tidak termasuk dalam-O3
. Lihatg++ -c -Q -O2 --help=optimizers | grep unroll
.Menggunakan Intels icc Versi 11.1 pada 2.4GHz Intel Core 2 Duo saya dapatkan
Itu sangat dekat dengan 9,6 Gflops ideal.
EDIT:
Ups, sambil melihat kode rakitan, tampaknya icc tidak hanya membuat vektor perkalian, tetapi juga menarik penambahan dari loop. Memaksa semantik yang lebih ketat kode tidak lagi vektor:
EDIT2:
Seperti yang diminta:
Lingkaran dalam kode dentang terlihat seperti ini:
EDIT3:
Akhirnya, dua saran: Pertama, jika Anda menyukai jenis tolok ukur ini, pertimbangkan untuk menggunakan
rdtsc
instruksi istead darigettimeofday(2)
. Ini jauh lebih akurat dan memberikan waktu dalam siklus, yang biasanya merupakan hal yang Anda minati. Untuk gcc dan teman, Anda dapat mendefinisikannya seperti ini:Kedua, Anda harus menjalankan program benchmark beberapa kali dan menggunakan kinerja terbaik saja . Dalam sistem operasi modern banyak hal terjadi secara paralel, cpu mungkin berada dalam mode hemat daya frekuensi rendah, dll. Menjalankan program berulang kali memberi Anda hasil yang lebih dekat ke kasus ideal.
sumber
addsd
's danmulsd
' atau apakah mereka dalam kelompok seperti pada output perakitan saya? Saya juga mendapatkan hanya sekitar 1 gagal / siklus ketika kompiler mencampurnya (yang saya dapatkan tanpa-march=native
). Bagaimana perubahan kinerja jika Anda menambahkan garisadd=mul;
di awal fungsiaddmul(...)
?addsd
dansubsd
memang dicampur dalam versi yang tepat. Saya mencoba dentang 3.0 juga, itu tidak mencampur instruksi dan ia datang sangat dekat dengan 2 jepit / siklus pada core 2 duo. Ketika saya menjalankan kode yang sama pada laptop saya core i5, pencampuran kode tidak ada bedanya. Saya mendapatkan sekitar 3 jepit / siklus dalam kedua kasus.icc
sebelumnya, dapatkah Anda memeriksa perakitannya?