Почему T * может быть передано в регистр, а unique_ptr<t> не может? - программирование

Почему T * может быть передано в регистр, а unique_ptr<t> не может?

Я смотрю выступление Чендлера Каррута в CppCon 2019:

Нет абстракций с нулевой стоимостью

в нем он приводит пример того, как он был удивлен тем, сколько накладных расходов вы понесли, используя std::unique_ptr<int> над int*; этот сегмент начинается примерно в момент времени 17:25.

Вы можете взглянуть на результаты компиляции его примера пары фрагментов (godbolt.org) - чтобы убедиться, что, действительно, кажется, что компилятор не хочет передавать значение unique_ptr - которое в Дело в том, что в нижней строке просто адрес - внутри регистра, только в прямой памяти.

Около 27:00 г-н Каррут отмечает, что ABI C++ требует, чтобы параметры по значению (некоторые, но не все; возможно, непримитивные типы? Нетривиально-конструируемые типы?) Передавались в -память, а не в реестре.

Мои вопросы:

  1. Это требование ABI на некоторых платформах? (что?) Или, может быть, это просто пессимизация в определенных сценариях?
  2. Почему ABI такой? То есть, если поля структуры/класса вписываются в регистры или даже в один регистр - почему мы не можем передать его в этот регистр?
  3. Обсуждает ли этот комитет по стандартам C++ этот вопрос в последние годы или когда-либо?

PS - чтобы не оставить этот вопрос без кода:

Обычный указатель:

void bar(int* ptr) noexcept;
void baz(int* ptr) noexcept;

void foo(int* ptr) noexcept {
    if (*ptr > 42) {
        bar(ptr); 
        *ptr = 42; 
    }
    baz(ptr);
}

Уникальный указатель:

using std::unique_ptr;
void bar(int* ptr) noexcept;
void baz(unique_ptr<int> ptr) noexcept;

void foo(unique_ptr<int> ptr) noexcept {
    if (*ptr > 42) { 
        bar(ptr.get());
        *ptr = 42; 
    }
    baz(std::move(ptr));
}
4b9b3361

Ответ 1

  1.    Это на самом деле требование ABI, или, может быть, это просто пессимизация в определенных сценариях?

Одним из примеров является Приложение к бинарному интерфейсу приложения System V для архитектуры AMD64. Этот ABI предназначен для 64-битных x86-совместимых процессоров (архитектура Linux x86_64). Он используется в Solaris, Linux, FreeBSD, macOS, Windows Подсистема для Linux:

Если у объекта C++ есть нетривиальный конструктор копирования или нетривиальный деструктор, он передается по невидимой ссылке (объект заменяется в список параметров по указателю, который имеет класс INTEGER).

Объект с нетривиальным конструктором копирования или нетривиальным деструктором не может быть передается по значению, потому что такие объекты должны иметь четко определенные адреса. Подобные проблемы применяются при возврате объекта из функции.

Обратите внимание, что только 2 регистра общего назначения могут использоваться для передачи 1 объекта с тривиальным конструктором копирования и тривиальным деструктором, то есть только значения объектов с sizeof не более 16 могут быть переданы в регистрах. См. Соглашения о вызовах от Agner Fog для получения подробной информации о соглашениях о вызовах, в частности §7.1 Передача и возврат объектов. Существуют отдельные соглашения о вызовах для передачи типов SIMD в регистрах.

Для других архитектур ЦП существуют разные ABI.


  1. Почему ABI такой? То есть, если поля структуры/класса вписываются в регистры или даже в один регистр - почему мы не можем передать его в этот регистр?

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

Педантично, деструкторы действуют на объекты:

Объект занимает область хранения в период его строительства ([class.cdtor]), в течение его срока службы и в период его разрушения.

и объект не может существовать в C++, если для него не выделено адресуемое хранилище, потому что идентификатор объекта является его адресом.

Когда требуется адрес объекта с тривиальным конструктором копирования, который хранится в регистрах, компилятор может просто сохранить объект в памяти и получить адрес. Если конструктор копирования нетривиален, с другой стороны, компилятор не может просто сохранить его в памяти, ему скорее нужно вызвать конструктор копирования, который берет ссылку и, следовательно, требует адрес объекта в регистрах. Соглашение о вызовах, вероятно, не может зависеть от того, был ли конструктор копирования встроен в вызываемый объект или нет.

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

void f(long*);
void g(long a) { f(&a); }

в x86_64 с System V ABI компилируется в:

g(long):                             // Argument a is in rdi.
        push    rax                  // Align stack, faster sub rsp, 8.
        mov     qword ptr [rsp], rdi // Store the value of a in rdi into the stack to create an object.
        mov     rdi, rsp             // Load the address of the object on the stack into rdi.
        call    f(long*)             // Call f with the address in rdi.
        pop     rax                  // Faster add rsp, 8.
        ret                          // The destructor of the stack object is trivial, no code to emit.

В своем наводящем на размышления выступлении Чендлер Кэррут упоминает, что для реализации разрушительного действия, которое могло бы улучшить положение вещей, может потребоваться переломное изменение ABI (среди прочего). IMO, изменение ABI могло бы быть неразрывным, если функции, использующие новый ABI, явно соглашаются иметь новую другую связь, например объявите их в блоке extern "C++20" {} (возможно, в новом встроенном пространстве имен для переноса существующих API). Так что только код, скомпилированный с новыми объявлениями функций с новой связью, может использовать новый ABI.

Обратите внимание, что ABI не применяется, когда вызываемая функция была встроена. Как и при генерации кода во время компиляции, компилятор может встроить функции, определенные в других единицах перевода, или использовать пользовательские соглашения о вызовах.

Ответ 2

С обычными ABI нетривиальный деструктор → не может пройти в регистрах

(Иллюстрация точки в ответе @MaximEgorushkin с использованием примера @harold в комментарии; исправлено в соответствии с комментарием @Yakk.)

Если вы компилируете:

struct Foo { int bar; };
Foo test(Foo byval) { return byval; }

вы получаете:

test(Foo):
        mov     eax, edi
        ret

т.е. объект Foo передается в test в регистре (edi), а также возвращается в регистр (eax).

Когда деструктор не является тривиальным (как пример OP std::unique_ptr) - Обычные ABI требуют размещения в стеке. Это верно, даже если деструктор вообще не использует адрес объекта.

Таким образом, даже в крайнем случае деструктора бездействия, если вы компилируете:

struct Foo2 {
    int bar;
    ~Foo2() {  }
};

Foo2 test(Foo2 byval) { return byval; }

вы получаете:

test(Foo2):
        mov     edx, DWORD PTR [rsi]
        mov     rax, rdi
        mov     DWORD PTR [rdi], edx
        ret

с бесполезной загрузкой и хранением.

Ответ 3

  Это требование ABI на некоторых платформах? (что?) Или, может быть, это просто пессимизация в определенных сценариях?

Если что-то видно на границе блока комплиментации, то независимо от того, определено оно явно или нет, оно становится частью ABI.

Почему ABI такой?

Основная проблема заключается в том, что регистры все время сохраняются и восстанавливаются при перемещении вниз и вверх по стеку вызовов. Поэтому нецелесообразно иметь на них ссылку или указатель.

Встраивание и вытекающие из него оптимизации - это хорошо, когда это происходит, но разработчик ABI не может рассчитывать на это. Они должны спроектировать ABI в худшем случае. Я не думаю, что программисты были бы очень довольны компилятором, в котором ABI менялся в зависимости от уровня оптимизации.

Тривиально копируемый тип может быть передан в регистрах, потому что логическая операция копирования может быть разделена на две части. Параметры копируются в регистры, используемые вызывающей стороной для передачи параметров, а затем вызываемой стороной копируется в локальную переменную. Таким образом, имеет ли локальная переменная место в памяти или нет - это только вопрос вызываемого абонента.

С другой стороны, для типа, в котором должен использоваться конструктор копирования или перемещения, операция копирования не может быть разделена таким образом, поэтому он должен быть передан в память.

Обсуждает ли этот комитет по стандартам C++ этот вопрос в последние годы или когда-либо?

Я понятия не имею, рассматривали ли органы по стандартизации это.

Очевидным решением для меня было бы добавить надлежащие деструктивные ходы (а не текущий полпути "действительного, но в остальном неопределенного состояния") к языку, а затем ввести способ пометить тип как допускающий "тривиальные деструктивные ходы". "даже если это не позволяет тривиальные копии.

но такое решение БУДЕТ требовать взлома ABI существующего кода для реализации для существующих типов, что может принести немало сопротивления (хотя разрывы ABI в результате новых стандартных версий C++ не являются беспрецедентными, например, std::изменения строки в C++ 11 привели к разрыву ABI.

Ответ 4

Что такое std::unique_ptr - это класс, умный указатель уникальной стратегии владения.

Из Википедии умный указатель

В информатике умный указатель - это абстрактный тип данных, который имитирует указатель, предоставляя дополнительные функции, такие как автоматический управление памятью или проверка границ.

Если я упрощу - unique_ptr может выглядеть как следующий класс:

template<typene T>
class unique_ptr {
// since we've removed default copy constructor and assignment operator 
// we are sure that only one instance of the class holds raw pointer
unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr& )= delete;
public:
  // this constructor take ownership on a raw pointer
   costexpr unique_ptr(T* px) noexcept: 
     px_(px) 
   {}
   // this constructor and assignment operators extracting raw pointer from existing  and takes ownership
   unique_ptr(unique_ptr&& c) noexept:
     px_(c.px_)
   {
     c.px_  = nullptr;
   }
   unique_ptr& operator=(unique_ptr&& rhs) noexcept {
      px_ = rhs.px_;
      rhs.px_ = nullptr;
      return *this; 
   }
   const T* operator->() const {
       return px_;
   } 
   ~unique_ptr() {
      delete px_;
   }
private:
  T* px_;
};

Поскольку C++ автоматически вызывает деструктор для любого объекта класса, выходящего из стека (в том числе в исключительном регистре), что означает, что если мы поместим необработанный указатель в класс - этот класс может эффективно контролировать время жизни объекта кучи. Так что нам больше не нужно беспокоиться о ручном управлении памятью с помощью new/delete. Мы уверены, что в нашем случае вызывается delete для нашего объекта.
Такая абстракция делает жизнь программиста максимально простой - поскольку он/она может писать более короткий и в то же время более безопасный код, не беспокоясь об утечках памяти и т.д. Но с точки зрения производительности и использования памяти - наш код стал больше по размеру и медленнее - с C++ генерирует больше инструкций процессора для него.

Это "цена абстракции", то есть мы платим памятью и процессором за более короткий и безопасный код.

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

Постскриптум Вы можете найти более подробную информацию о распространенных в настоящее время абстракциях C++ (включая указатели витрин и их стратегии, функциональные объекты и т.д.), Реализованных стандартной библиотекой или надстройкой C++ 11+. В книге Андрея Александреску "Современный C++ дизайн".