Differenza di prestazioni tra MSVC e GCC per codice di moltiplicazione della matrice altamente ottimizzato

Vedo una grande differenza di prestazioni tra codice compilato in MSVC (su Windows) e GCC (su Linux) per un sistema Ivy Bridge. Il codice fa una moltiplicazione densa della matrice. Sto ottenendo il 70% dei picchi di punta con GCC e solo il 50% con MSVC. Penso di aver isolato la differenza su come entrambi convertono i seguenti tre elementi intrinseci.

__m256 breg0 = _mm256_loadu_ps(&b[8*i]) _mm256_add_ps(_mm256_mul_ps(arge0,breg0), tmp0) 

GCC lo fa

 vmovups ymm9, YMMWORD PTR [rax-256] vmulps ymm9, ymm0, ymm9 vaddps ymm8, ymm8, ymm9 

MSVC fa questo

 vmulps ymm1, ymm2, YMMWORD PTR [rax-256] vaddps ymm3, ymm1, ymm3 

Qualcuno potrebbe spiegarmi se e perché queste due soluzioni potrebbero dare una così grande differenza nelle prestazioni?

Nonostante MSVC usi una istruzione in meno, lega il carico al mult e forse questo lo rende più dipendente (forse il carico non può essere fatto fuori ordine)? Voglio dire Ivy Bridge può fare un caricamento AVX, un AVX mult e un AVX aggiungere in un ciclo di clock, ma questo richiede che ciascuna operazione sia indipendente.

Forse il problema sta altrove? Puoi vedere il codice completo dell’assembly per GCC e MSVC per il ciclo più interno qui sotto. Puoi vedere il codice C ++ per il loop qui Loop srotolare per ottenere il massimo rendimento con Ivy Bridge e Haswell

g ++ -S -masm = intel matrix.cpp -O3 -mavx -fopenmp

 .L4: vbroadcastss ymm0, DWORD PTR [rcx+rdx*4] add rdx, 1 add rax, 256 vmovups ymm9, YMMWORD PTR [rax-256] vmulps ymm9, ymm0, ymm9 vaddps ymm8, ymm8, ymm9 vmovups ymm9, YMMWORD PTR [rax-224] vmulps ymm9, ymm0, ymm9 vaddps ymm7, ymm7, ymm9 vmovups ymm9, YMMWORD PTR [rax-192] vmulps ymm9, ymm0, ymm9 vaddps ymm6, ymm6, ymm9 vmovups ymm9, YMMWORD PTR [rax-160] vmulps ymm9, ymm0, ymm9 vaddps ymm5, ymm5, ymm9 vmovups ymm9, YMMWORD PTR [rax-128] vmulps ymm9, ymm0, ymm9 vaddps ymm4, ymm4, ymm9 vmovups ymm9, YMMWORD PTR [rax-96] vmulps ymm9, ymm0, ymm9 vaddps ymm3, ymm3, ymm9 vmovups ymm9, YMMWORD PTR [rax-64] vmulps ymm9, ymm0, ymm9 vaddps ymm2, ymm2, ymm9 vmovups ymm9, YMMWORD PTR [rax-32] cmp esi, edx vmulps ymm0, ymm0, ymm9 vaddps ymm1, ymm1, ymm0 jg .L4 

MSVC / FAc / O2 / openmp / arch: AVX …

 vbroadcastss ymm2, DWORD PTR [r10] lea rax, QWORD PTR [rax+256] lea r10, QWORD PTR [r10+4] vmulps ymm1, ymm2, YMMWORD PTR [rax-320] vaddps ymm3, ymm1, ymm3 vmulps ymm1, ymm2, YMMWORD PTR [rax-288] vaddps ymm4, ymm1, ymm4 vmulps ymm1, ymm2, YMMWORD PTR [rax-256] vaddps ymm5, ymm1, ymm5 vmulps ymm1, ymm2, YMMWORD PTR [rax-224] vaddps ymm6, ymm1, ymm6 vmulps ymm1, ymm2, YMMWORD PTR [rax-192] vaddps ymm7, ymm1, ymm7 vmulps ymm1, ymm2, YMMWORD PTR [rax-160] vaddps ymm8, ymm1, ymm8 vmulps ymm1, ymm2, YMMWORD PTR [rax-128] vaddps ymm9, ymm1, ymm9 vmulps ymm1, ymm2, YMMWORD PTR [rax-96] vaddps ymm10, ymm1, ymm10 dec rdx jne SHORT $LL3@AddDot4x4_ 

MODIFICARE:

Eseguo il benchmark del codice clacando le operazioni di virgola mobile totale come 2.0*n^3 dove n è la larghezza della matrice quadrata e si divide per il tempo misurato con omp_get_wtime() . Ripeto il loop più volte. Nell’output sottostante l’ho ripetuto 100 volte.

L’uscita da MSVC2012 su un Intel Xeon E5 1620 (Ivy Bridge) turbo per tutti i core è 3.7 GHz

 maximum GFLOPS = 236.8 = (8-wide SIMD) * (1 AVX mult + 1 AVX add) * (4 cores) * 3.7 GHz n 64, 0.02 ms, GFLOPs 0.001, GFLOPs/s 23.88, error 0.000e+000, efficiency/core 40.34%, efficiency 10.08%, mem 0.05 MB n 128, 0.05 ms, GFLOPs 0.004, GFLOPs/s 84.54, error 0.000e+000, efficiency/core 142.81%, efficiency 35.70%, mem 0.19 MB n 192, 0.17 ms, GFLOPs 0.014, GFLOPs/s 85.45, error 0.000e+000, efficiency/core 144.34%, efficiency 36.09%, mem 0.42 MB n 256, 0.29 ms, GFLOPs 0.034, GFLOPs/s 114.48, error 0.000e+000, efficiency/core 193.37%, efficiency 48.34%, mem 0.75 MB n 320, 0.59 ms, GFLOPs 0.066, GFLOPs/s 110.50, error 0.000e+000, efficiency/core 186.66%, efficiency 46.67%, mem 1.17 MB n 384, 1.39 ms, GFLOPs 0.113, GFLOPs/s 81.39, error 0.000e+000, efficiency/core 137.48%, efficiency 34.37%, mem 1.69 MB n 448, 3.27 ms, GFLOPs 0.180, GFLOPs/s 55.01, error 0.000e+000, efficiency/core 92.92%, efficiency 23.23%, mem 2.30 MB n 512, 3.60 ms, GFLOPs 0.268, GFLOPs/s 74.63, error 0.000e+000, efficiency/core 126.07%, efficiency 31.52%, mem 3.00 MB n 576, 3.93 ms, GFLOPs 0.382, GFLOPs/s 97.24, error 0.000e+000, efficiency/core 164.26%, efficiency 41.07%, mem 3.80 MB n 640, 5.21 ms, GFLOPs 0.524, GFLOPs/s 100.60, error 0.000e+000, efficiency/core 169.93%, efficiency 42.48%, mem 4.69 MB n 704, 6.73 ms, GFLOPs 0.698, GFLOPs/s 103.63, error 0.000e+000, efficiency/core 175.04%, efficiency 43.76%, mem 5.67 MB n 768, 8.55 ms, GFLOPs 0.906, GFLOPs/s 105.95, error 0.000e+000, efficiency/core 178.98%, efficiency 44.74%, mem 6.75 MB n 832, 10.89 ms, GFLOPs 1.152, GFLOPs/s 105.76, error 0.000e+000, efficiency/core 178.65%, efficiency 44.66%, mem 7.92 MB n 896, 13.26 ms, GFLOPs 1.439, GFLOPs/s 108.48, error 0.000e+000, efficiency/core 183.25%, efficiency 45.81%, mem 9.19 MB n 960, 16.36 ms, GFLOPs 1.769, GFLOPs/s 108.16, error 0.000e+000, efficiency/core 182.70%, efficiency 45.67%, mem 10.55 MB n 1024, 17.74 ms, GFLOPs 2.147, GFLOPs/s 121.05, error 0.000e+000, efficiency/core 204.47%, efficiency 51.12%, mem 12.00 MB 

Dal momento che abbiamo coperto il problema dell’allineamento, direi che è questo: http://en.wikipedia.org/wiki/Out-of-order_execution

Poiché g ++ emette un’istruzione di caricamento autonoma, il processore può riordinare le istruzioni per ottenere i dati successivi necessari per l’aggiunta e la moltiplicazione. MSVC lanciare un puntatore su mul rende il carico e mul legato alla stessa istruzione, quindi cambiare l’ordine di esecuzione delle istruzioni non aiuta nulla.

EDIT: I server di Intel con tutti i documenti sono meno arrabbiati oggi, quindi ecco ulteriori ricerche sul motivo per cui l’esecuzione fuori servizio è (parte della) risposta.

Prima di tutto, sembra che il tuo commento sia completamente corretto riguardo alla possibilità per la versione MSVC dell’istruzione di moltiplicazione di decodificare in μ-op separati che possono essere ottimizzati dal motore fuori servizio di una CPU. La parte divertente qui è che i moderni sequencer di microcodice sono programmabili, quindi il comportamento effettivo dipende sia dall’hardware che dal firmware. Le differenze nell’assemblaggio generato sembrano provenire da GCC e MSVC, ciascuno dei quali cerca di combattere diversi potenziali colli di bottiglia. La versione GCC cerca di dare spazio al motore fuori servizio (come abbiamo già visto). Tuttavia, la versione MSVC finisce sfruttando una funzionalità chiamata “fusione micro-op”. Questo a causa dei limiti di pensionamento μ-op. La fine della pipeline può solo andare in pensione di 3 μ-op per tick. La fusione micro-op, in casi specifici, richiede due μ-op che devono essere eseguiti su due diverse unità di esecuzione (ad es. Lettura della memoria e aritmetica) e li lega a un singolo μ-op per la maggior parte della pipeline. La μ-op fusa viene divisa solo nei due μ-op reali subito prima dell’assegnazione dell’unità di esecuzione. Dopo l’esecuzione, gli op sono di nuovo fusi, consentendo loro di ritirarsi come uno.

Il motore fuori servizio vede solo la μ-op fusa, quindi non può allontanare il carico op dalla moltiplicazione. Questo fa sì che la pipeline si blocchi mentre attende che il prossimo operando finisca il suo viaggio in autobus.

TUTTI I LINK !!!: http://download-software.intel.com/sites/default/files/managed/71/2e/319433-017.pdf

http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf

http://www.agner.org/optimize/microarchitecture.pdf

http://www.agner.org/optimize/optimizing_assembly.pdf

http://www.agner.org/optimize/instruction_tables.ods (NOTA: Excel si lamenta che questo foglio di calcolo è parzialmente corrotto o altrimenti impreciso, quindi aperto a proprio rischio e pericolo, ma non sembra essere malevolo e per il resto della mia ricerca, Agner Fog è fantastico. Dopo aver triggersto la fase di ripristino di Excel, l’ho trovata piena di tonnellate di dati eccezionali)

http://cs.nyu.edu/courses/fall13/CSCI-GA.3033-008/Microprocessor-Report-Sandy-Bridge-Spans-Generations-243901.pdf

http://www.syncfusion.com/Content/downloads/ebook/Assembly_Language_Succinctly.pdf


MOLTO SUCCESSO EDIT: Wow, c’è stato qualche aggiornamento interessante alla discussione qui. Immagino di essermi sbagliato su quanto della pipeline sia effettivamente interessata dalla fusione micro op. Forse ci sono più guadagni perfetti di quanto mi aspettassi dalle differenze nel controllo della condizione del ciclo, in cui le istruzioni non utilizzate permettono a GCC di interlacciare il confronto e saltare con l’ultimo carico vettoriale e le fasi aritmetiche?

 vmovups ymm9, YMMWORD PTR [rax-32] cmp esi, edx vmulps ymm0, ymm0, ymm9 vaddps ymm1, ymm1, ymm0 jg .L4 

Posso confermare che l’utilizzo del codice GCC in Visual Studio migliora effettivamente le prestazioni. Ho fatto questo convertendo il file object GCC in Linux per lavorare in Visual Studio . L’efficienza è passata dal 50% al 60% utilizzando tutti e quattro i core (e dal 60% al 70% per un singolo core).

Microsoft ha rimosso l’assembly in linea dal codice a 64 bit e ha anche infranto il loro dissembler a 64 bit in modo che il codice non possa essere simile senza modifiche ( ma la versione a 32 bit funziona ancora ). Evidentemente pensavano che gli intrinseci sarebbero stati sufficienti ma, come in questo caso, hanno torto.

Forse le istruzioni fuse dovrebbero essere intrinseche separate?

Ma Microsoft non è l’unico che produce codice intrinseco meno ottimale. Se inserisci il codice qui sotto in http://gcc.godbolt.org/ puoi vedere cosa fanno Clang, ICC e GCC. ICC ha dato prestazioni ancora peggiori di MSVC. Sta usando vinsertf128 ma non so perché. Non sono sicuro di cosa stia facendo Clang, ma sembra essere più vicino a GCC solo in un ordine diverso (e più codice).

Questo spiega perché Agner Fog ha scritto nel suo manuale ” Ottimizzare le subroutine nel linguaggio assembly ” in merito a “svantaggi dell’uso di funzioni intrinseche”:

Il compilatore può modificare il codice o implementarlo in modo meno efficiente di quanto previsto dal programmatore. Potrebbe essere necessario guardare il codice generato dal compilatore per vedere se è ottimizzato come previsto dal programmatore.

Questo è deludente per il caso dell’uso di elementi intrinseci. Ciò significa che uno deve scrivere ancora soemtimes codice assembly a 64 bit o trovare un compilatore che implementa gli elementi intrinseci come previsto dal programmatore. In questo caso solo GCC sembra farlo (e forse Clang).

 #include  extern "C" void AddDot4x4_vec_block_8wide(const int n, const float *a, const float *b, float *c, const int stridea, const int strideb, const int stridec) { const int vec_size = 8; __m256 tmp0, tmp1, tmp2, tmp3, tmp4, tmp5, tmp6, tmp7; tmp0 = _mm256_loadu_ps(&c[0*vec_size]); tmp1 = _mm256_loadu_ps(&c[1*vec_size]); tmp2 = _mm256_loadu_ps(&c[2*vec_size]); tmp3 = _mm256_loadu_ps(&c[3*vec_size]); tmp4 = _mm256_loadu_ps(&c[4*vec_size]); tmp5 = _mm256_loadu_ps(&c[5*vec_size]); tmp6 = _mm256_loadu_ps(&c[6*vec_size]); tmp7 = _mm256_loadu_ps(&c[7*vec_size]); for(int i=0; i 

MSVC ha fatto esattamente quello che gli hai chiesto. Se si desidera vmovups un’istruzione vmovups , utilizzare l’intrinseco _mm256_loadu_ps .