web-dev-qa-db-fra.com

Pourquoi ce code SSE 6 fois plus lent sans VZEROUPPER sur Skylake?

J'ai essayé de comprendre un problème de performances dans une application et je l'ai finalement réduit à un problème vraiment étrange. Le morceau de code suivant s'exécute 6 fois plus lentement sur un processeur Skylake (i5-6500) si l'instruction VZEROUPPER est mise en commentaire. J'ai testé les processeurs Sandy Bridge et Ivy Bridge et les deux versions fonctionnent à la même vitesse, avec ou sans VZEROUPPER.

Maintenant, j'ai une assez bonne idée de ce que fait VZEROUPPER et je pense que cela ne devrait pas avoir d'importance du tout pour ce code lorsqu'il n'y a pas d'instructions codées VEX et aucun appel à une fonction qui pourrait les contenir. Le fait qu'il ne fonctionne pas sur d'autres processeurs compatibles AVX semble prendre en charge cela. Tout comme le tableau 11-2 dans le Intel® 64 et IA-32 Architectures Optimization Reference Manual

Alors, quoi de neuf?

La seule théorie qui me reste est qu'il y a un bogue dans le CPU et qu'il déclenche incorrectement la procédure "enregistrer la moitié supérieure des registres AVX" là où il ne devrait pas. Ou autre chose aussi étrange.

C'est main.cpp:

#include <immintrin.h>

int slow_function( double i_a, double i_b, double i_c );

int main()
{
    /* DAZ and FTZ, does not change anything here. */
    _mm_setcsr( _mm_getcsr() | 0x8040 );

    /* This instruction fixes performance. */
    __asm__ __volatile__ ( "vzeroupper" : : : );

    int r = 0;
    for( unsigned j = 0; j < 100000000; ++j )
    {
        r |= slow_function( 
                0.84445079384884236262,
                -6.1000481519580951328,
                5.0302160279288017364 );
    }
    return r;
}

et c'est slow_function.cpp:

#include <immintrin.h>

int slow_function( double i_a, double i_b, double i_c )
{
    __m128d sign_bit = _mm_set_sd( -0.0 );
    __m128d q_a = _mm_set_sd( i_a );
    __m128d q_b = _mm_set_sd( i_b );
    __m128d q_c = _mm_set_sd( i_c );

    int vmask;
    const __m128d zero = _mm_setzero_pd();

    __m128d q_abc = _mm_add_sd( _mm_add_sd( q_a, q_b ), q_c );

    if( _mm_comigt_sd( q_c, zero ) && _mm_comigt_sd( q_abc, zero )  )
    {
        return 7;
    }

    __m128d discr = _mm_sub_sd(
        _mm_mul_sd( q_b, q_b ),
        _mm_mul_sd( _mm_mul_sd( q_a, q_c ), _mm_set_sd( 4.0 ) ) );

    __m128d sqrt_discr = _mm_sqrt_sd( discr, discr );
    __m128d q = sqrt_discr;
    __m128d v = _mm_div_pd(
        _mm_shuffle_pd( q, q_c, _MM_SHUFFLE2( 0, 0 ) ),
        _mm_shuffle_pd( q_a, q, _MM_SHUFFLE2( 0, 0 ) ) );
    vmask = _mm_movemask_pd(
        _mm_and_pd(
            _mm_cmplt_pd( zero, v ),
            _mm_cmple_pd( v, _mm_set1_pd( 1.0 ) ) ) );

    return vmask + 1;
}

La fonction se compile en ceci avec clang:

 0:   f3 0f 7e e2             movq   %xmm2,%xmm4
 4:   66 0f 57 db             xorpd  %xmm3,%xmm3
 8:   66 0f 2f e3             comisd %xmm3,%xmm4
 c:   76 17                   jbe    25 <_Z13slow_functionddd+0x25>
 e:   66 0f 28 e9             movapd %xmm1,%xmm5
12:   f2 0f 58 e8             addsd  %xmm0,%xmm5
16:   f2 0f 58 ea             addsd  %xmm2,%xmm5
1a:   66 0f 2f eb             comisd %xmm3,%xmm5
1e:   b8 07 00 00 00          mov    $0x7,%eax
23:   77 48                   ja     6d <_Z13slow_functionddd+0x6d>
25:   f2 0f 59 c9             mulsd  %xmm1,%xmm1
29:   66 0f 28 e8             movapd %xmm0,%xmm5
2d:   f2 0f 59 2d 00 00 00    mulsd  0x0(%rip),%xmm5        # 35 <_Z13slow_functionddd+0x35>
34:   00 
35:   f2 0f 59 ea             mulsd  %xmm2,%xmm5
39:   f2 0f 58 e9             addsd  %xmm1,%xmm5
3d:   f3 0f 7e cd             movq   %xmm5,%xmm1
41:   f2 0f 51 c9             sqrtsd %xmm1,%xmm1
45:   f3 0f 7e c9             movq   %xmm1,%xmm1
49:   66 0f 14 c1             unpcklpd %xmm1,%xmm0
4d:   66 0f 14 cc             unpcklpd %xmm4,%xmm1
51:   66 0f 5e c8             divpd  %xmm0,%xmm1
55:   66 0f c2 d9 01          cmpltpd %xmm1,%xmm3
5a:   66 0f c2 0d 00 00 00    cmplepd 0x0(%rip),%xmm1        # 63 <_Z13slow_functionddd+0x63>
61:   00 02 
63:   66 0f 54 cb             andpd  %xmm3,%xmm1
67:   66 0f 50 c1             movmskpd %xmm1,%eax
6b:   ff c0                   inc    %eax
6d:   c3                      retq   

Le code généré est différent avec gcc mais il montre le même problème. Une ancienne version du compilateur Intel génère encore une autre variation de la fonction qui montre le problème aussi mais seulement si main.cpp n'est pas construit avec le compilateur Intel car il insère des appels pour initialiser certaines de ses propres bibliothèques qui finissent probablement par faire VZEROUPPER quelque part.

Et bien sûr, si le tout est construit avec le support AVX afin que les intrinsèques soient transformées en instructions codées VEX, il n'y a pas de problème non plus.

J'ai essayé de profiler le code avec perf sur linux et la plupart du runtime atterrit généralement sur 1-2 instructions mais pas toujours les mêmes selon la version du code que je profil (gcc, clang, intel) . Le raccourcissement de la fonction semble faire disparaître progressivement la différence de performance, il semble donc que plusieurs instructions soient à l'origine du problème.

EDIT: Voici une version d'assemblage pure, pour Linux. Commentaires ci-dessous.

    .text
    .p2align    4, 0x90
    .globl _start
_start:

    #vmovaps %ymm0, %ymm1  # This makes SSE code crawl.
    #vzeroupper            # This makes it fast again.

    movl    $100000000, %ebp
    .p2align    4, 0x90
.LBB0_1:
    xorpd   %xmm0, %xmm0
    xorpd   %xmm1, %xmm1
    xorpd   %xmm2, %xmm2

    movq    %xmm2, %xmm4
    xorpd   %xmm3, %xmm3
    movapd  %xmm1, %xmm5
    addsd   %xmm0, %xmm5
    addsd   %xmm2, %xmm5
    mulsd   %xmm1, %xmm1
    movapd  %xmm0, %xmm5
    mulsd   %xmm2, %xmm5
    addsd   %xmm1, %xmm5
    movq    %xmm5, %xmm1
    sqrtsd  %xmm1, %xmm1
    movq    %xmm1, %xmm1
    unpcklpd    %xmm1, %xmm0
    unpcklpd    %xmm4, %xmm1

    decl    %ebp
    jne    .LBB0_1

    mov $0x1, %eax
    int $0x80

Ok, donc comme suspect dans les commentaires, l'utilisation d'instructions codées VEX provoque le ralentissement. L'utilisation de VZEROUPPER l'efface. Mais cela n'explique toujours pas pourquoi.

Si je comprends bien, ne pas utiliser VZEROUPPER est censé impliquer un coût de transition vers les anciennes instructions SSE mais pas un ralentissement permanent de celles-ci. Surtout pas si important. Prendre comptez au-dessus de la boucle, le rapport est au moins 10x, peut-être plus.

J'ai essayé de jouer un peu avec l'Assemblée et les instructions flottantes sont aussi mauvaises que les instructions doubles. Je n'ai pas pu non plus localiser le problème avec une seule instruction.

34
Olivier

Vous rencontrez une pénalité pour "mixage" non-VEX SSE et instructions encodées VEX - même si votre application visible dans son ensemble n'utilise évidemment aucune instruction AVX !

Avant Skylake, ce type de pénalité n'était qu'une pénalité unique transition, lors du passage du code qui utilisait vex au code qui ne l'a pas fait, ou vice versa. C'est-à-dire que vous n'avez jamais payé d'amende pour tout ce qui s'est produit dans le passé, sauf si vous mélangez activement VEX et non-VEX. Dans Skylake, cependant, il existe un état où les instructions non-VEX SSE paient une pénalité d'exécution continue élevée, même sans mélange supplémentaire.

Directement de la bouche du cheval, voici Figure 11-1  1 - l'ancien diagramme de transition (pré-Skylake):

Pre-Skylake Transition Penalties

Comme vous pouvez le voir, toutes les pénalités (flèches rouges) vous amènent à un nouvel état, auquel cas il n'y a plus de pénalité pour avoir répété cette action. Par exemple, si vous arrivez à l'état sale supérieur en exécutant un AVX 256 bits, et que vous exécutez ensuite l'héritage SSE, vous payez un une seule fois pénalité pour passer à l'état préservé non INIT supérieur, mais vous ne payez aucune pénalité après cela.

Dans Skylake, tout est différent selon Figure 11-2 :

Skylake Penalties

Il y a moins de pénalités dans l'ensemble, mais d'une manière critique pour votre cas, l'une d'entre elles est une boucle automatique: la pénalité pour l'exécution d'un héritage SSE ( Pénalité A dans l'instruction Figure 11-2) dans l'état sale supérieur vous maintient dans cet état. C'est ce qui vous arrive - toute instruction AVX vous met dans un état supérieur sale, ce qui ralentit encore davantage SSE exécution vers le bas.

Voici ce qu'Intel dit (section 11.3) à propos de la nouvelle pénalité:

La microarchitecture Skylake implémente une machine d'état différente de celle des générations précédentes pour gérer la transition d'état YMM associée au mélange SSE et instructions AVX. Elle n'enregistre plus l'intégralité de l'état YMM supérieur lors de l'exécution d'un SSE lorsqu'elle est dans l'état "Modifié et non enregistré", mais enregistre les bits supérieurs du registre individuel. Par conséquent, le mélange SSE et AVX subiront une pénalité associée à une dépendance de registre des registres de destination utilisés et opération de mélange supplémentaire sur les bits supérieurs des registres de destination.

Ainsi, la pénalité est apparemment assez importante - elle doit constamment mélanger les bits supérieurs pour les conserver, et elle rend également les instructions qui semblent apparemment indépendantes, car il existe une dépendance sur les bits supérieurs cachés. Par exemple xorpd xmm0, xmm0 ne rompt plus la dépendance à la valeur précédente de xmm0, car le résultat dépend en fait des bits supérieurs cachés de ymm0 qui ne sont pas effacés par le xorpd. Ce dernier effet est probablement ce qui tue vos performances, car vous aurez maintenant de très longues chaînes de dépendance qui ne vous attendraient pas de l'analyse habituelle.

C'est l'un des pires pièges de performances: où le comportement/les meilleures pratiques pour l'architecture antérieure sont essentiellement opposés à l'architecture actuelle. Vraisemblablement, les architectes matériels avaient une bonne raison de faire le changement, mais cela ajoute simplement un autre "gotcha" à la liste des problèmes de performances subtils.

Je voudrais déposer un bogue contre le compilateur ou le runtime qui a inséré cette instruction AVX et n'a pas suivi avec un VZEROUPPER.

Mise à jour: Selon l'OP commentaire ci-dessous, le code (AVX) incriminé a été inséré par l'éditeur de liens d'exécution ld et un bug existe déjà.


1 D'après Intel manuel d'optimisation .

39
BeeOnRope

Je viens de faire quelques expériences (sur un Haswell). La transition entre les états propres et sales n'est pas coûteuse, mais l'état sale rend chaque opération vectorielle non-VEX dépendante de la valeur précédente du registre de destination. Dans votre cas, par exemple movapd %xmm1, %xmm5 aura une fausse dépendance sur ymm5 qui empêche l'exécution dans le désordre. Cela explique pourquoi vzeroupper est nécessaire après le code AVX.

17
A Fog