web-dev-qa-db-fra.com

Existe-t-il des données statistiques significatives pour justifier de ne pas définir de dépassement arithmétique d'entier signé?

La norme C spécifie explicitement le dépassement d'entier signé comme ayant un comportement non défini . Pourtant, la plupart des CPU implémentent des arithmétiques signées avec une sémantique définie pour le débordement (sauf peut-être pour le débordement de division: x / 0 et INT_MIN / -1).

Les rédacteurs de compilateurs ont profité du undefinedness de ces débordements pour ajouter des optimisations plus agressives qui ont tendance à casser le code hérité de manière très subtile. Par exemple, ce code peut avoir fonctionné sur des compilateurs plus anciens mais ne fonctionne plus sur les versions actuelles de gcc et clang:

/* Tncrement a by a value in 0..255, clamp a to positive integers.
   The code relies on 32-bit wrap-around, but the C Standard makes
   signed integer overflow undefined behavior, so sum_max can now 
   return values less than a. There are Standard compliant ways to
   implement this, but legacy code is what it is... */
int sum_max(int a, unsigned char b) {
    int res = a + b;
    return (res >= a) ? res : INT_MAX;
}

Existe-t-il des preuves tangibles que ces optimisations valent la peine? Existe-t-il des études comparatives documentant les améliorations réelles sur des exemples réels ou même sur des repères classiques?

Je suis venu avec cette question pendant que je regardais ceci: C++ Now 2018: John Regehr "Keynote de clôture: Comportements indéfinis et optimisations du compilateur"

Je marque c et c ++ car le problème est similaire dans les deux langues mais les réponses pourraient être différentes.

Je ne connais pas les études et les statistiques, mais oui, il y a certainement des optimisations en tenant compte de ce que les compilateurs font réellement. Et oui, ils sont très importants (vectorisation de boucle tldr par exemple).

Outre les optimisations du compilateur, il y a un autre aspect à prendre en compte. Avec UB, vous obtenez des entiers signés C/C++ pour qu'ils se comportent arithmétiquement comme vous vous y attendez mathématiquement. Par exemple, x + 10 > x Reste vrai maintenant (pour un code valide bien sûr), mais ne serait pas sur un comportement de bouclage.

J'ai trouvé un excellent article Comment un débordement signé non défini permet des optimisations dans GCC du blog de Krister Walfridsson listant quelques optimisations qui prennent en compte le débordement signé UB. Les exemples suivants en sont issus. J'y ajoute des exemples c ++ et Assembly.

Si les optimisations semblent trop simples, sans intérêt ou sans impact, rappelez-vous que ces optimisations ne sont que des étapes dans une chaîne d'optimisations beaucoup plus grande. Et l'effet papillon se produit car une optimisation apparemment sans importance à une étape antérieure peut déclencher une optimisation beaucoup plus percutante à une étape ultérieure.

Si les exemples semblent absurdes (qui écrirait x * 10 > 0) Gardez à l'esprit que vous pouvez très facilement accéder à ce type d'exemples en C et C++ avec des constantes, des macros, des modèles. En outre, le compilateur peut accéder à ce type d'exemples lors de l'application de transformations et d'optimisations dans son IR.

Simplification des expressions entières signées

  • Élimine la multiplication par rapport à 0

    (x * c) cmp 0   ->   x cmp 0 
    
    bool foo(int x) { return x * 10 > 0 }
    
    foo(int):
            test    edi, edi
            setg    al
            ret
    
  • Élimine la division après la multiplication

    (x * c1)/c2 -> x * (c1/c2) si c1 est divisible par c2

    int foo(int x) { return (x * 20) / 10; }
    
    foo(int):
            lea     eax, [rdi+rdi]
            ret
    
  • Élimine la négation

    (-x)/(-y) -> x/y

    int foo(int x, int y) { return (-x) / (-y); }
    
    foo(int, int):
            mov     eax, edi
            cdq
            idiv    esi
            ret
    
  • Simplifiez les comparaisons qui sont toujours vraies ou fausses

    x + c < x       ->   false
    x + c <= x      ->   false
    x + c > x       ->   true
    x + c >= x      ->   true
    
    bool foo(int x) { return x + 10 >= x; }
    
    foo(int):
            mov     eax, 1
            ret
    
  • Élimine la négation dans les comparaisons

    (-x) cmp (-y)   ->   y cmp x
    
    bool foo(int x, int y) { return -x < -y; }
    
    foo(int, int):
            cmp     edi, esi
            setg    al
            ret
    
  • Réduire l'amplitude des constantes

    x + c > y       ->   x + (c - 1) >= y
    x + c <= y      ->   x + (c - 1) < y
    
    bool foo(int x, int y) { return x + 10 <= y; }
    
    foo(int, int):
            add     edi, 9
            cmp     edi, esi
            setl    al
            ret
    
  • Élimine les constantes dans les comparaisons

    (x + c1) cmp c2         ->   x cmp (c2 - c1)
    (x + c1) cmp (y + c2)   ->   x cmp (y + (c2 - c1)) if c1 <= c2
    

    La deuxième transformation n'est valide que si c1 <= c2, car elle introduirait sinon un débordement lorsque y a la valeur INT_MIN.

    bool foo(int x) { return x + 42 <= 11; }
    
    foo(int):
            cmp     edi, -30
            setl    al
            ret
    

Arithmétique du pointeur et promotion du type

Si une opération ne déborde pas, nous obtiendrons le même résultat si nous effectuons l'opération dans un type plus large. Cela est souvent utile lorsque vous effectuez des tâches telles que l'indexation de tableaux sur des architectures 64 bits - les calculs d'index sont généralement effectués à l'aide d'un entier 32 bits, mais les pointeurs sont 64 bits, et le compilateur peut générer du code plus efficace lorsque le dépassement de signature n'est pas défini par la promotion des entiers 32 bits en opérations 64 bits au lieu de générer des extensions de type.

Un autre aspect de cela est que le débordement non défini garantit que un [i] et un [i + 1] sont adjacents. Cela améliore l'analyse des accès à la mémoire pour la vectorisation, etc.

Il s'agit d'une optimisation très importante car la vectorisation de boucle est l'un des algorithmes d'optimisation les plus efficaces et les plus efficaces.

Il est plus difficile à démontrer. Mais je me souviens avoir rencontré une situation lors du changement d'un index de unsigned à signed qui a considérablement amélioré l'assembly généré. Malheureusement, je ne m'en souviens pas ou ne le reproduis pas maintenant. Je reviendrai plus tard si je le découvre.

Calculs de plage de valeurs

Le compilateur garde une trace de la plage de valeurs possibles des variables à chaque point du programme, c'est-à-dire pour le code tel que

int x = foo();
if (x > 0) {
  int y = x + 5;
  int z = y / 4;

il détermine que x a la plage [1, INT_MAX] après l'instruction if, et peut ainsi déterminer que y a la plage [6, INT_MAX] car le débordement n'est pas autorisé. Et la ligne suivante peut être optimisée en int z = y >> 2; Car le compilateur sait que y n'est pas négatif.

auto foo(int x)
{
    if (x <= 0)
        __builtin_unreachable();

    return (x + 5) / 4;
}
foo(int):
        lea     eax, [rdi+5]
        sar     eax, 2
        ret

Le débordement indéfini aide les optimisations qui doivent comparer deux valeurs (car le cas d'habillage donnerait des valeurs possibles de la forme [INT_MIN, (INT_MIN+4)] ou [6, INT_MAX] Qui empêche toutes les comparaisons utiles avec < Ou >), Comme

  • Modification des comparaisons x<y En true ou false si les plages de x et y ne se chevauchent pas
  • Modification de min(x,y) ou max(x,y) en x ou y si les plages ne se chevauchent pas
  • Changement de abs(x) en x ou -x Si la plage ne dépasse pas 0
  • Changer x/c En x>>log2(c) si x>0 Et la constante c est une puissance de 2
  • Changer x%c En x&(c-1) si x>0 Et la constante c est une puissance de 2

Analyse et optimisation de boucle

L'exemple canonique de la raison pour laquelle un débordement signé non défini aide les optimisations de boucle est que les boucles comme

for (int i = 0; i <= m; i++)

sont garantis de se terminer pour un débordement non défini. Cela aide les architectures qui ont des instructions de boucle spécifiques, car elles ne gèrent généralement pas les boucles infinies.

Mais le débordement signé non défini permet de nombreuses autres optimisations de boucle. Toutes les analyses telles que la détermination du nombre d'itérations, la transformation des variables d'induction et le suivi des accès à la mémoire utilisent tout dans les sections précédentes pour faire son travail. En particulier, l'ensemble des boucles qui peuvent être vectorisées est fortement réduit lorsqu'un débordement signé est autorisé .

21
bolov

Ce n'est pas tout à fait un exemple d'optimisation, mais une conséquence utile d'un comportement non défini est -ftrapv commutateur de ligne de commande de GCC/clang. Il insère du code qui plante votre programme en cas de dépassement d'entier.

Cela ne fonctionnera pas sur les entiers non signés, conformément à l'idée que le débordement non signé est intentionnel.

Le libellé de la norme sur le débordement d'entier signé garantit que les gens n'écriront pas de code débordant exprès, donc ftrapv est un outil utile pour découvrir un débordement involontaire.

7
anatolyg

Voici une véritable petite référence, le type à bulles. J'ai comparé les horaires sans/avec -fwrapv (ce qui signifie que le débordement est UB/pas UB). Voici les résultats (en secondes):

                   -O3     -O3 -fwrapv    -O1     -O1 -fwrapv
Machine1, clang    5.2     6.3            6.8     7.7
Machine2, clang-8  4.2     7.8            6.4     6.7
Machine2, gcc-8    6.6     7.4            6.5     6.5

Comme vous pouvez le voir, le not-UB (-fwrapv) la version est presque toujours plus lente, la plus grande différence est assez grande, 1,85x.

Voici le code. Notez que j'ai intentionnellement choisi une implémentation, ce qui devrait produire une plus grande différence pour ce test.

#include <stdio.h>
#include <stdlib.h>

void bubbleSort(int *a, long n) {
        bool swapped;
        for (int i = 0; i < n-1; i++) {
                swapped = false;
                for (int j = 0; j < n-i-1; j++) {
                        if (a[j] > a[j+1]) {
                                int t = a[j];
                                a[j] = a[j+1];
                                a[j+1] = t;
                                swapped = true;
                        }
                }

                if (!swapped) break;
        }
}

int main() {
        int a[8192];

        for (int j=0; j<100; j++) {
                for (int i=0; i<8192; i++) {
                        a[i] = Rand();
                }

                bubbleSort(a, 8192);
        }
}
4
geza

La réponse est en fait dans votre question:

Pourtant, la plupart des CPU implémentent des arithmétiques signées avec une sémantique définie

Je ne peux pas penser à un processeur que vous pouvez acheter aujourd'hui qui n'utilise pas l'arithmétique à deux compléments pour les entiers signés, mais ce n'était pas toujours le cas.

Le langage C a été inventé en 1972. À l'époque, les mainframes IBM 7090 existaient toujours. Tous les ordinateurs n'étaient pas complices.

Avoir défini le langage (et le comportement de débordement) autour du compliment 2s aurait été préjudiciable à la génération de code sur des machines qui ne l'étaient pas.

De plus, comme cela a déjà été dit, spécifier que le débordement signé doit être UB permet au compilateur de produire un meilleur code, car il peut actualiser les chemins de code qui résultent d'un débordement signé, en supposant que cela ne se produira jamais.

Si je comprends bien qu'il est destiné à fixer la somme de a et b à 0 .... INT_MAX sans contour, je peux penser à deux façons d'écrire cette fonction de manière conforme.

Tout d'abord, le cas général inefficace qui fonctionnera sur tous les processeurs:

int sum_max(int a, unsigned char b) {
    if (a > std::numeric_limits<int>::max() - b)
        return std::numeric_limits<int>::max();
    else
        return a + b;
}

Deuxièmement, la manière spécifique étonnamment efficace du complément 2s:

int sum_max2(int a, unsigned char b) {
    unsigned int buffer;
    std::memcpy(&buffer, &a, sizeof(a));
    buffer += b;
    if (buffer > std::numeric_limits<int>::max())
        buffer = std::numeric_limits<int>::max();
    std::memcpy(&a, &buffer, sizeof(a));
    return a;
}

L'assembleur résultant peut être vu ici: https://godbolt.org/z/F42IXV

2
Richard Hodges