Возвращает ли 2-кортеж менее эффективно, чем std:: pair?

Рассмотрим этот код:

#include <utility>
#include <tuple>

std::pair<int, int> f1()
{
    return std::make_pair(0x111, 0x222);
}

std::tuple<int, int> f2()
{
    return std::make_tuple(0x111, 0x222);
}

Clang 3 и 4 генерируют одинаковый код для обоих на x86-64:

f1():
 movabs rax,0x22200000111
 ret    
f2():
 movabs rax,0x11100000222 ; opposite packing order, not important
 ret    

Но Clang 5 генерирует другой код для f2():

f2():
 movabs rax,0x11100000222
 mov    QWORD PTR [rdi],rax
 mov    rax,rdi
 ret    

Как сделать GCC 4 - GCC 7:

f2():
 movabs rdx,0x11100000222
 mov    rax,rdi
 mov    QWORD PTR [rdi],rdx ; GCC 4-6 use 2 DWORD stores
 ret

Почему сгенерированный код хуже, когда возвращает std::tuple который помещается в один регистр, против std::pair? Это кажется особенно странным, поскольку Clang 3 и 4 казались оптимальными, а 5 - нет.

Попробуйте это здесь: https://godbolt.org/g/T2Yqrj

Ответы

Ответ 1

Краткий ответ заключается в том, что libstc++ стандартной библиотеки libstc++ используемая gcc и clang в Linux, реализует std::tuple с нетривиальным конструктором перемещения (в частности, базовый класс _Tuple_impl имеет нетривиальный конструктор перемещения). С другой стороны, конструкторы копирования и перемещения для std::pair все по умолчанию.

Это, в свою очередь, вызывает связанную с C++ -ABI разницу в соглашении о вызовах для возврата этих объектов из функций, а также для передачи их по значению.

Гори Детали

Вы провели свои тесты на Linux, который придерживается SysV x86-64 ABI. В этом ABI есть специальные правила для передачи или возврата классов или структур в функции, о которых вы можете прочитать здесь. Особый случай нас интересует, получат ли два поля int в этих структурах класс INTEGER или класс MEMORY.

Недавняя версия спецификации ABI имеет следующее:

Классификация агрегатных (структур и массивов) и объединяющих типов работает следующим образом:

  1. Если размер объекта больше восьми восьмибайтов или содержит выровненные поля un-, он имеет класс MEMORY 12.
  2. Если объект C++ имеет нетривиальный конструктор копирования или нетривиальный деструктор 13, он передается по невидимой ссылке (объект заменяется в списке параметров указателем с классом INTEGER) 14.
  3. Если размер совокупности превышает один восьмибайтовый, каждый классифицируется отдельно. Каждый восьмибайт инициализируется в классе NO_CLASS.
  4. Каждое поле объекта классифицируется рекурсивно, поэтому всегда учитываются два поля. Полученный класс рассчитывается по классам полей в восьмибайтовом

Здесь применимо условие (2). Обратите внимание, что в нем упоминаются только конструкторы копирования, а не конструкторы перемещения, но вполне очевидно, что это просто дефект в спецификации, учитывая введение конструкторов перемещения, которые обычно должны быть включены в любой алгоритм классификации, где конструкторы копирования были включены ранее., В частности, IA-64 cxx-abi, о котором gcc задокументировано , включает конструкторы перемещения:

Если тип параметра нетривиален для целей вызовов, вызывающая сторона должна выделить место для временного объекта и передать это временное значение по ссылке. В частности:

  • Пространство выделяется вызывающей стороной обычным образом для временного, обычно в стеке.

и тогда определение нетривиально:

Тип считается нетривиальным для вызовов, если:

  • у него есть нетривиальный конструктор копирования, конструктор перемещения или деструктор, или
  • все его конструкторы копирования и перемещения удаляются.

Таким образом, поскольку tuple не считается тривиально копируемым с точки зрения ABI, он получает обработку MEMORY, что означает, что ваша функция должна заполнять выделенный объект стека, передаваемый вызываемым в rdi. Функция std::pair может просто передавать всю структуру в rax поскольку она помещается в один EIGHTBYTE и имеет класс INTEGER.

Это имеет значение? Да, строго говоря, автономная функция, подобная той, которую вы скомпилировали, будет менее эффективной для tuple так как эта ABI-версия "запекается".

Однако часто компилятор сможет увидеть тело функции и встроить его или выполнить межпроцедурный анализ, даже если он не встроен. В обоих случаях ABI больше не важен, и, вероятно, оба подхода будут одинаково эффективными, по крайней мере, с приличным оптимизатором. Например, давайте вызовем ваши функции f1() и f2() и немного посчитаем результат:

int add_pair() {
  auto p = f1();
  return p.first + p.second;
}

int add_tuple() {
  auto t = f2();
  return std::get<0>(t) + std::get<1>(t);
}

В принципе, метод add_tuple начинается с недостатка, так как он должен вызывать f2() который менее эффективен, и он также должен создать временный объект кортежа в стеке, чтобы он мог передать его в f2 как скрытый параметр. Ну, не важно, обе функции полностью оптимизированы, чтобы просто возвращать правильное значение напрямую:

add_pair():
  mov eax, 819
  ret
add_tuple():
  mov eax, 819
  ret

Таким образом, в целом вы можете сказать, что эффект этой проблемы ABI с tuple будет относительно приглушенным: он добавляет небольшие фиксированные накладные расходы к функциям, которые должны соответствовать ABI, но это действительно будет иметь значение только в относительном смысле для очень маленьких функций - но такие функции могут быть объявлены в месте, где они могут быть встроены (или, если нет, вы оставляете производительность на столе).

libcst C++ против lib C++ +

Как объяснено выше, это проблема ABI, а не проблема оптимизации, как таковая. И clang, и gcc уже оптимизируют код библиотеки в максимально возможной степени в соответствии с ограничениями ABI - если они сгенерируют код, подобный f1() для случая std::tuple они нарушат ABI-совместимые вызывающие объекты.

Это ясно видно, если вы переключитесь на использование libC++ а не Linux по умолчанию для libstdC++ - эта реализация не имеет явного конструктора перемещения (как упоминает Марк Глисс в комментариях, они застряли в этой реализации). для обратной совместимости). Теперь clang (и, вероятно, gcc, хотя я не пробовал), генерирует одинаковый оптимальный код в обоих случаях:

f1():                                 # @f1()
        movabs  rax, 2345052143889
        ret
f2():                                 # @f2()
        movabs  rax, 2345052143889
        ret

Ранние версии Clang

Почему версии clang компилируют это по-другому? Это была просто ошибка в clang или ошибка в спецификации, в зависимости от того, как вы на нее смотрите. Спецификация явно не включает конструкцию перемещения в случаях, когда необходимо передать скрытый указатель на временный объект. не соответствовал IA-64 C++ ABI. Например, скомпилированный способ, которым clang использовал это, был несовместим с gcc или более новыми версиями clang. Спецификация была в конечном итоге обновлена, и поведение clang изменилось в версии 5.0.

Обновление: Марк Глисс упоминает в комментариях, что изначально была путаница во взаимодействии нетривиальных конструкторов движений и C++ ABI, и clang изменил свое поведение в какой-то момент, что, вероятно, объясняет переключение:

Спецификация ABI для некоторых случаев передачи аргументов с участием конструкторов перемещения была неясной, и когда они были прояснены, clang изменился, чтобы следовать ABI. Это, наверное, один из тех случаев.