Почему T * может быть передано в регистр, а unique_ptr <T>не может? - PullRequest
79 голосов
/ 11 октября 2019

Я смотрю выступление Чендлера Каррута в 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));
}

Ответы [ 3 ]

46 голосов
/ 11 октября 2019
  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 для других архитектур ЦП.


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

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

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

AnОбъект занимает область хранения в период его строительства ([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 не применяется, когда вызванная функция была встроена. Как и при генерации кода во время компиляции, компилятор может встроить функции, определенные в других единицах перевода, или использовать пользовательские соглашения о вызовах.

8 голосов
/ 11 октября 2019

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

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

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

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

, вы получите:

test(Foo):
        mov     eax, edi
        ret

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

Когда деструктор не является тривиальным (как пример std::unique_ptr OP) - Обычные 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

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

2 голосов
/ 12 октября 2019

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

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

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

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

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

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

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

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

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

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

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

...