Pourquoi GCC ne peut-il pas supposer que std :: vector :: size ne changera pas dans cette boucle?

14

J'ai revendiqué à un collègue qui if (i < input.size() - 1) print(0);serait optimisé dans cette boucle afin que ce input.size()ne soit pas lu à chaque itération, mais il s'avère que ce n'est pas le cas!

void print(int x) {
    std::cout << x << std::endl;
}

void print_list(const std::vector<int>& input) {
    int i = 0;
    for (size_t i = 0; i < input.size(); i++) {
        print(input[i]);
        if (i < input.size() - 1) print(0);
    }
}

Selon l' explorateur du compilateur avec les options gcc, -O3 -fno-exceptionsnous lisons actuellement input.size()chaque itération et utilisons leapour effectuer une soustraction!

        movq    0(%rbp), %rdx
        movq    8(%rbp), %rax
        subq    %rdx, %rax
        sarq    $2, %rax
        leaq    -1(%rax), %rcx
        cmpq    %rbx, %rcx
        ja      .L35
        addq    $1, %rbx

Fait intéressant, dans Rust, cette optimisation se produit. Il semble que isoit remplacé par une variable jqui est décrémentée à chaque itération, et le test i < input.size() - 1est remplacé par quelque chose comme j > 0.

fn print(x: i32) {
    println!("{}", x);
}

pub fn print_list(xs: &Vec<i32>) {
    for (i, x) in xs.iter().enumerate() {
        print(*x);
        if i < xs.len() - 1 {
            print(0);
        }
    }
}

Dans l' explorateur du compilateur, l'assembly correspondant ressemble à ceci:

        cmpq    %r12, %rbx
        jae     .LBB0_4

J'ai vérifié et je suis sûr que r12c'est xs.len() - 1et rbxc'est le comptoir. Plus tôt, il y a un addpour rbxet un movextérieur dans la boucle r12.

Pourquoi est-ce? Il semble que si GCC est en mesure d'aligner le size()et operator[]comme il l'a fait, il devrait pouvoir savoir que size()cela ne change pas. Mais peut-être que l'optimiseur de GCC juge qu'il ne vaut pas la peine de le retirer dans une variable? Ou peut-être y a-t-il un autre effet secondaire possible qui rendrait cela dangereux - quelqu'un le sait-il?

Jonathan Chan
la source
1
C'est aussi printlnprobablement une méthode complexe, le compilateur peut avoir du mal à prouver que printlncela ne mute pas le vecteur.
Mooing Duck
1
@MooingDuck: Un autre thread serait UB de course aux données. Les compilateurs peuvent et supposent que cela ne se produit pas. Le problème ici est l'appel de fonction non en ligne à cout.operator<<(). Le compilateur ne sait pas que cette fonction de boîte noire n'obtient pas de référence à std::vectorpartir d'un global.
Peter Cordes
@PeterCordes: vous avez raison de dire que les autres threads ne sont pas une explication autonome, et la complexité de printlnou operator<<est la clé.
Mooing Duck
Le compilateur ne connaît pas la sémantique de ces méthodes externes.
user207421

Réponses:

10

L'appel de fonction non en ligne à cout.operator<<(int)est une boîte noire pour l'optimiseur (car la bibliothèque est simplement écrite en C ++ et tout ce que l'optimiseur voit est un prototype; voir la discussion dans les commentaires). Il doit supposer que toute mémoire qui pourrait éventuellement être pointée par une variable globale a été modifiée.

(Ou l' std::endlappel. BTW, pourquoi forcer un flush de cout à ce point au lieu d'imprimer simplement un '\n'?)

par exemple pour tout ce qu'il sait, std::vector<int> &inputest une référence à une variable globale, et l'un de ces appels de fonction modifie cette variable globale . (Ou il y a un global vector<int> *ptrquelque part, ou il y a une fonction qui renvoie un pointeur vers un static vector<int>dans une autre unité de compilation, ou d'une autre manière qu'une fonction pourrait obtenir une référence à ce vecteur sans que nous lui transmettions une référence.

Si vous aviez une variable locale dont l'adresse n'avait jamais été prise, le compilateur pouvait supposer que les appels de fonction non en ligne ne pouvaient pas la muter. Parce qu'il n'y aurait aucun moyen pour une variable globale de contenir un pointeur sur cet objet. ( C'est ce qu'on appelle l' analyse d'échappement ). C'est pourquoi le compilateur peut conserver size_t iun registre à travers les appels de fonction. ( int ipeut simplement être optimisé car il est masqué par size_t iet non utilisé autrement).

Il pourrait faire de même avec un vectorpointeur local (c'est-à-dire pour les pointeurs base, end_size et end_capacity.)

ISO C99 a une solution à ce problème: int *restrict foo. De nombreux C ++ prennent en charge compiles int *__restrict foopromettre que la mémoire pointée par fooest uniquement accessible via ce pointeur. Le plus souvent utile dans les fonctions qui prennent 2 tableaux, et vous voulez promettre au compilateur qu'elles ne se chevauchent pas. Il peut donc auto-vectoriser sans générer de code pour vérifier cela et exécuter une boucle de secours.

Le PO commente:

Dans Rust, une référence non mutable est une garantie globale que personne d'autre ne mute la valeur à laquelle vous avez une référence (équivalent à C ++ restrict)

Cela explique pourquoi Rust peut effectuer cette optimisation mais pas C ++.


Optimiser votre C ++

Évidemment, vous devez utiliser auto size = input.size();une fois en haut de votre fonction pour que le compilateur sache que c'est un invariant de boucle. Les implémentations C ++ ne résolvent pas ce problème pour vous, vous devez donc le faire vous-même.

Vous devrez peut-être également const int *data = input.data();hisser des charges du pointeur de données du std::vector<int>"bloc de contrôle". Il est regrettable que l'optimisation puisse nécessiter des changements de source très non idiomatiques.

Rust est un langage beaucoup plus moderne, conçu après que les développeurs de compilateurs ont appris ce qui était possible dans la pratique pour les compilateurs. Il montre vraiment d'autres façons, aussi, y compris l' exposition portably certains des processeurs cool stuff peuvent faire via i32.count_ones, rotate, balayage bits, etc. Il est vraiment stupide que l' ISO C ++ ne fonctionne toujours pas exposer l' une de ces portably, à l' exception std::bitset::count().

Peter Cordes
la source
1
Le code OP a toujours le test si le vecteur est pris en valeur. Ainsi, même si GCC pourrait optimiser dans ce cas, il ne le fait pas.
noyer
1
La norme définit le comportement de operator<<pour ces types d'opérandes; donc en Standard C ++ ce n'est pas une boîte noire et le compilateur peut supposer qu'il fait ce que dit la documentation. Peut-être qu'ils veulent soutenir les développeurs de bibliothèques en ajoutant un comportement non standard ...
MM
2
L'optimiseur pourrait être alimenté par le comportement que la norme impose, mon point est que cette optimisation est autorisée par la norme mais le fournisseur du compilateur choisit de l'implémenter de la manière que vous décrivez et renonce à cette optimisation
MM
2
@MM Il n'a pas dit objet aléatoire, j'ai dit un vecteur défini par l'implémentation. Rien dans la norme n'interdit à une implémentation d'avoir un vecteur défini par l'implémentation que l'opérateur << modifie et autorise l'accès à ce vecteur d'une manière définie par l'implémentation. coutpermet à un objet d'une classe définie par l'utilisateur dérivé de streambufd'être associé au flux utilisant cout.rdbuf. De même, un objet dérivé de ostreampeut être associé à cout.tie.
Ross Ridge
2
@PeterCordes - Je ne serais pas si confiant sur les vecteurs locaux: dès qu'une fonction membre se détache, les sections locales se sont effectivement échappées car le thispointeur est implicitement passé. Cela pourrait se produire dans la pratique dès le constructeur. Considérez cette boucle simple - je n'ai vérifié que la boucle principale de gcc (de L34:à jne L34), mais elle se comporte définitivement comme si les membres du vecteur s'étaient échappés (en les chargeant de la mémoire à chaque itération).
BeeOnRope