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);