Pourquoi les compilateurs C ++ n’optimisent-ils pas les lectures et écritures à l’extérieur des données structurées plutôt que les variables locales distinctes?

J’essaie de créer un tableau local de valeurs POD (par exemple, double ) avec max_size fixe connue au moment de la compilation, puis de lire une valeur de size exécution ( size <= max_size ) et de traiter les premiers éléments de size de ce tableau.

La question qui se pose est de savoir pourquoi le compilateur n’élimine pas les lectures et écritures de la stack quand arr et size sont placés dans la même struct / class , par opposition au cas où arr et size sont des variables locales indépendantes.

Voici mon code:

 #include  constexpr std::size_t max_size = 64; extern void process_value(double& ref_value); void test_distinct_array_and_size(std::size_t size) { double arr[max_size]; std::size_t arr_size = size; for (std::size_t i = 0; i < arr_size; ++i) process_value(arr[i]); } void test_array_and_size_in_local_struct(std::size_t size) { struct { double arr[max_size]; std::size_t size; } array_wrapper; array_wrapper.size = size; for (std::size_t i = 0; i < array_wrapper.size; ++i) process_value(array_wrapper.arr[i]); } 

Sortie d’ test_distinct_array_and_size pour test_distinct_array_and_size de Clang avec -O3:

 test_distinct_array_and_size(unsigned long): # @test_distinct_array_and_size(unsigned long) push r14 push rbx sub rsp, 520 mov r14, rdi test r14, r14 je .LBB0_3 mov rbx, rsp .LBB0_2: # =>This Inner Loop Header: Depth=1 mov rdi, rbx call process_value(double&) add rbx, 8 dec r14 jne .LBB0_2 .LBB0_3: add rsp, 520 pop rbx pop r14 ret 

Sortie d’ test_array_and_size_in_local_struct pour test_array_and_size_in_local_struct :

 test_array_and_size_in_local_struct(unsigned long): # @test_array_and_size_in_local_struct(unsigned long) push r14 push rbx sub rsp, 520 mov qword ptr [rsp + 512], rdi test rdi, rdi je .LBB1_3 mov r14, rsp xor ebx, ebx .LBB1_2: # =>This Inner Loop Header: Depth=1 mov rdi, r14 call process_value(double&) inc rbx add r14, 8 cmp rbx, qword ptr [rsp + 512] jb .LBB1_2 .LBB1_3: add rsp, 520 pop rbx pop r14 ret 

Les derniers compilateurs GCC et MSVC font fondamentalement la même chose avec les lectures et écritures de stack.

Comme on peut le constater, les lectures et écritures dans la variable array_wrapper.size de la stack ne sont pas optimisées dans ce dernier cas. Il existe une écriture de valeur de size dans l’emplacement [rsp + 512] avant le début de la boucle et une lecture à partir de cet emplacement après chaque itération.

Le compilateur s’attend donc à vouloir modifier array_wrapper.size partir de l’ array_wrapper.size process_value(array_wrapper.arr[i]) (en prenant l’adresse de l’élément de tableau actuel et en lui appliquant des décalages étranges?)

Mais, si nous essayons de le faire à partir de cet appel, ce comportement ne serait-il pas indéfini?

Quand on réécrit la boucle de la manière suivante

 for (std::size_t i = 0, sz = array_wrapper.size; i < sz; ++i) process_value(array_wrapper.arr[i]); 

, ces lectures inutiles à la fin de chaque itération auront disparu. Mais l’écriture initiale dans [rsp + 512] restra, ce qui signifie que le compilateur s’attend toujours à ce que nous puissions accéder à la variable array_wrapper.size à cet emplacement à partir de ces appels process_value (en effectuant une étrange magie basée sur l’offset).

Pourquoi?

Est-ce que ce n’est qu’un petit défaut dans les implémentations des compilateurs modernes (qui, espérons-le, sera corrigé prochainement)? Ou bien la norme C ++ exige-t-elle en effet un tel comportement qui conduise à la génération de code moins efficace chaque fois que nous plaçons un tableau et sa taille dans la même classe?

PS

Je me rends compte que mon exemple de code ci-dessus peut sembler un peu artificiel. Mais considérez ceci: j’aimerais utiliser un modèle de classe boost::container::static_vector dans mon code pour des manipulations “à la C ++” plus sûres et plus pratiques avec des tableaux pseudo-dynamics d’éléments POD. Donc, mon PODVector contiendra un tableau et un size_t dans la même classe:

 template class PODVector { static_assert(std::is_pod::value, "T must be a POD type"); private: T _data[MaxSize]; std::size_t _size = 0; public: using iterator = T *; public: static constexpr std::size_t capacity() noexcept { return MaxSize; } constexpr PODVector() noexcept = default; explicit constexpr PODVector(std::size_t initial_size) : _size(initial_size) { assert(initial_size <= capacity()); } constexpr std::size_t size() const noexcept { return _size; } constexpr void resize(std::size_t new_size) { assert(new_size <= capacity()); _size = new_size; } constexpr iterator begin() noexcept { return _data; } constexpr iterator end() noexcept { return _data + _size; } constexpr T & operator[](std::size_t position) { assert(position < _size); return _data[position]; } }; 

Usage:

 void test_pod_vector(std::size_t size) { PODVector arr(size); for (double& val : arr) process_value(val); } 

Si le problème décrit ci-dessus est en effet forcé par le standard C ++ (et n’est pas la faute des auteurs du compilateur), un tel PODVector ne sera jamais aussi efficace que l’utilisation brute d’un tableau et une variable “non liée” pour la taille. Et cela serait très mauvais pour C ++ en tant que langage qui veut des abstractions nulles.

En effet, void process_value(double& ref_value); accepte l’argument par référence. Le compilateur / optimiseur assume l’aliasing, c’est-à-dire que la fonction process_value peut modifier la mémoire accessible via la référence ref_value et donc ce membre de size après le tableau.

Le compilateur suppose que, puisque le array et la size appartiennent à un même object, la fonction array_wrapper fonction process_value peut potentiellement array_wrapper la référence en premier élément (lors de la première invocation) en référence à l’object (et la stocker ailleurs) unsigned char et lit ou remplace la totalité de sa représentation. Ainsi, après le retour de la fonction, l’état de l’object doit être rechargé à partir de la mémoire.

Lorsque size est un object autonome sur la stack, le compilateur / optimiseur part du principe que rien d’autre ne pourrait avoir de référence / pointeur sur celui-ci et le met en cache dans un registre.

Dans Chandler Carruth: Optimisation des structures émergentes du C ++, il explique pourquoi les optimiseurs ont des difficultés à appeler des fonctions acceptant des arguments de référence / pointeur. Utilisez les arguments de fonction de référence / pointeur uniquement lorsque cela est absolument nécessaire.

Si vous souhaitez modifier la valeur, l’option la plus performante est:

 double process_value(double value); 

Et alors:

 array_wrapper.arr[i] = process_value(array_wrapper.arr[i]); 

Ce changement permet un assemblage optimal :

 .L23: movsd xmm0, QWORD PTR [rbx] add rbx, 8 call process_value2(double) movsd QWORD PTR [rbx-8], xmm0 cmp rbx, rbp jne .L23 

Ou:

 for(double& val : arr) val = process_value(val);