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

85

Я смотрю выступление Чендлера Каррута в 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));
}
einpoklum
источник
8
Я не уверен , что требование ABI точно, но это не запрещает положить в регистры структур
Гарольда
6
Если бы мне пришлось угадывать, я бы сказал, что это связано с нетривиальными функциями-членами, нуждающимися в thisуказателе, который указывает на правильное местоположение. unique_ptrесть те. Раскрытие регистра для этой цели сведет на нет всю оптимизацию «прохода в регистре».
StoryTeller - Unslander Моника
2
itanium-cxx-abi.github.io/cxx-abi/abi.html#calls . Так что такое поведение обязательно. Почему? itanium-cxx-abi.github.io/cxx-abi/cxx-closed.html , найдите проблему C-7. Там есть какое-то объяснение, но оно не слишком подробное. Но да, это поведение не кажется мне логичным. Эти объекты могли бы нормально проходить через стек. Помещение их в стек, а затем передача ссылки (только для «нетривиальных» объектов) кажется пустой тратой.
Геза
6
Кажется, C ++ нарушает здесь свои принципы, что довольно печально. Я был уверен на 140%, что любой unique_ptr просто исчезает после компиляции. В конце концов это просто отложенный вызов деструктора, который известен во время компиляции.
One Man Monkey Squad
7
@MaximEgorushkin: Если бы вы написали это от руки, вы бы поместили указатель в регистр, а не в стек.
einpoklum

Ответы:

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

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

Максим Егорушкин
источник
Комментарии не для расширенного обсуждения; этот разговор был перенесен в чат .
Самуэль Лью
8

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

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

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

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

ты получаешь:

test(Foo):
        mov     eax, edi
        ret

т.е. Fooобъект передается testв register ( edi), а также возвращается в register ( 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

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

einpoklum
источник
Я не убежден этим аргументом. Нетривиальный деструктор не делает ничего, чтобы запретить правило «как будто». Если адрес не наблюдается, нет абсолютно никакой причины, почему он должен быть. Таким образом, соответствующий компилятор может с радостью поместить его в регистр, если это не изменит наблюдаемого поведения (а текущие компиляторы фактически сделают это, если будут известны вызывающие ).
ComicSansMS
1
К сожалению, это другой путь (я согласен, что кое-что из этого уже вне разумного). Если быть точным: я не уверен, что приведенные вами причины обязательно приведут к тому, что любой возможный ABI, позволяющий передавать ток std::unique_ptrв регистр, не будет соответствовать.
ComicSansMS
3
«тривиальный деструктор [ЦИТАТА НУЖНА]» явно ложный; если никакой код на самом деле не зависит от адреса, то «как если» означает, что адрес не должен существовать на реальном компьютере . Адрес должен существовать в абстрактной машине , но вещи в абстрактной машине, которые не влияют на реальную машину, - это вещи, которые как бы разрешено устранять.
Якк - Адам Невраумонт
2
@einpoklum В стандарте нет ничего, что бывают регистры штатов. Ключевое слово register просто говорит: «Вы не можете взять адрес». В отношении стандарта существует только абстрактная машина. «как будто» означает, что любая реализация реальной машины должна вести себя только «как если бы» абстрактная машина ведет себя, вплоть до поведения, не определенного стандартом. В настоящее время существуют очень сложные проблемы с наличием объекта в реестре, о котором все много говорили. Кроме того, соглашения о вызовах, которые стандарт также не обсуждает, имеют практические потребности.
Якк - Адам Невраумонт
1
@einpoklum Нет, в этой абстрактной машине все вещи имеют адреса; но адреса можно наблюдать только при определенных обстоятельствах. registerКлючевое слово было предназначено , чтобы сделать его тривиальным для физической машины в магазине что - то в реестре, блокируя вещи , которые практически делают его труднее «не имеют никакого адреса» в физической машине.
Якк - Адам Невраумонт
2

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

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

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

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

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

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

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

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

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

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

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

plugwash
источник
Можете ли вы рассказать о том, как правильные разрушительные шаги позволят передавать unique_ptr в регистр? Будет ли это потому, что это позволит отказаться от требования к адресуемому хранилищу?
einpoklum
Правильные деструктивные ходы позволили бы ввести концепцию тривиальных деструктивных ходов. Это позволило бы ABI разделить упомянутый тривиальный ход так же, как тривиальные копии могут быть сегодня.
plugwash
Хотя вы также хотели бы добавить правило, согласно которому компилятор может реализовывать передачу параметров в виде обычного перемещения или копирования, за которым следует «тривиальное разрушительное перемещение», чтобы гарантировать, что всегда можно передавать регистры независимо от того, откуда поступил параметр.
plugwash
Потому что размер регистра может содержать указатель, но структуру unique_ptr? Что такое sizeof (unique_ptr <T>)?
Мел Визо Мартинес
@MelVisoMartinez Вы можете сбить с толку unique_ptrи shared_ptrсемантику: shared_ptr<T>позволяет вам предоставить ctor 1) ptr x для производного объекта U, который будет удален со статическим типом U w / выражением delete x;(поэтому вам не нужен виртуальный dtor здесь) 2) или даже пользовательская функция очистки. Это означает, что состояние выполнения используется внутри shared_ptrблока управления для кодирования этой информации. OTOH unique_ptrне имеет такой функциональности и не кодирует поведение удаления в состоянии; единственный способ настроить очистку - это создать еще один экземпляр шаблона (другой тип класса).
любопытный парень
-1

Сначала нам нужно вернуться к тому, что значит передавать по значению и по ссылке.

Для языков, таких как Java и SML, передача по значению является простой (и нет передачи по ссылке), так же, как копирование значения переменной, так как все переменные являются просто скалярами и имеют встроенную семантику копирования: они либо считаются арифметическими. введите C ++ или «ссылки» (указатели с другим именем и синтаксисом).

В Си у нас есть скалярные и пользовательские типы:

  • Скаляры имеют числовое или абстрактное значение (указатели не являются числами, они имеют абстрактное значение), которое копируется.
  • Для агрегатных типов скопированы все их инициализированные элементы:
    • для типов продуктов (массивов и структур): рекурсивно копируются все члены структур и элементов массивов (синтаксис функции C не позволяет напрямую передавать массивы по значению, только массивы членов структуры, но это деталь ).
    • для типов сумм (объединений): значение «активного члена» сохраняется; очевидно, что член за членом не в порядке, так как не все члены могут быть инициализированы.

В C ++ пользовательские типы могут иметь пользовательскую семантику копирования, которая позволяет действительно «объектно-ориентированное» программирование с объектами, владеющими их ресурсами и операциями «глубокого копирования». В таком случае операция копирования на самом деле является вызовом функции, которая может почти выполнять произвольные операции.

Для структур C, скомпилированных как C ++, «копирование» по-прежнему определяется как вызов пользовательской операции копирования (либо конструктора, либо оператора присваивания), которая неявно генерируется компилятором. Это означает, что семантика программы общего подмножества C / C ++ отличается в C и C ++: в C копируется целый тип агрегата, в C ++ вызывается неявно сгенерированная функция копирования для копирования каждого члена; конечный результат заключается в том, что в любом случае каждый член копируется.

(Думаю, есть исключение, когда копируется структура внутри объединения.)

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

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

Для скалярного типа значение объекта - это значение объекта, чистое математическое значение, хранящееся в объекте.

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

Так что передача по значению на самом деле не вещь: это передача по вызову конструктора копирования , что менее привлекательно. Предполагается, что конструктор копирования будет выполнять разумную операцию «копирования» в соответствии с надлежащей семантикой типа объекта с учетом его внутренних инвариантов (которые являются абстрактными пользовательскими свойствами, а не внутренними свойствами C ++).

Передача по значению объекта класса означает:

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

Обратите внимание, что проблема не имеет отношения к тому, является ли сама копия объектом с адресом: все параметры функции являются объектами и имеют адрес (на уровне семантики языка).

Вопрос заключается в следующем:

  • копия - это новый объект, инициализированный чистым математическим значением (true pure rvalue) исходного объекта, как со скалярами;
  • или копия является значением оригинального объекта , как с классами.

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

Объекты класса должны быть созданы вызывающей стороной; у конструктора формально есть thisуказатель, но формализм здесь не уместен: все объекты формально имеют адрес, но только те, которые фактически используют свой адрес не чисто локальным образом (в отличие от *&i = 1;чисто локального использования адреса), должны иметь четкое определение адрес.

Объект должен обязательно передаваться по адресу, если он должен иметь адрес в обеих этих двух отдельно скомпилированных функциях:

void callee(int &i) {
  something(&i);
}

void caller() {
  int i;
  callee(i);
  something(&i);
}

Здесь, даже если something(address)это чистая функция или макрос или что-то (например printf("%p",arg)), которое не может сохранить адрес или связаться с другим объектом, у нас есть требование передавать по адресу, потому что адрес должен быть четко определен для уникального объекта, intкоторый имеет уникальный идентичность.

Мы не знаем, будет ли внешняя функция «чистой» с точки зрения адресов, переданных ей.

Здесь возможность реального использования адреса в нетривиальном конструкторе или деструкторе на стороне вызывающего абонента, вероятно, является причиной для выбора безопасного, упрощенного маршрута и присвоения объекту идентификатора в вызывающем устройстве и передачи его адреса, так как он делает убедитесь, что любое нетривиальное использование его адреса в конструкторе, после конструирования и в деструкторе непротиворечиво : thisдолжно казаться одинаковым в течение существования объекта.

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

struct file_handler { // don't use that class!
    file_handler () { this->fileno = -1; }
    file_handler (int f) { this->fileno = f; }
    file_handler (const file_handler& rhs) {
        if (this->fileno != -1)
            this->fileno = dup(rhs.fileno);
        else
            this->fileno = -1;
    }
    ~file_handler () {
        if (this->fileno != -1)
            close(this->fileno); 
    }
    file_handler &operator= (const file_handler& rhs);
};

Обратите внимание, что в этом случае, несмотря на явное использование указателя (явный синтаксис this->), идентичность объекта не имеет значения: компилятор вполне может использовать побитовое копирование объекта, чтобы переместить его и выполнить «копирование». Это основано на уровне «чистоты» использования thisв специальных функциях-членах (адрес не экранирует).

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

Чистота измеряется как «безусловно чистая» или «нечистая или неизвестная». Точка соприкосновения или верхняя граница семантики (фактически максимальная) или LCM (наименьшее общее кратное) "неизвестна". Так что ABI останавливается на неизвестности.

Резюме:

  • Некоторые конструкции требуют, чтобы компилятор определял идентичность объекта.
  • ABI определяется в терминах классов программ, а не в конкретных случаях, которые могут быть оптимизированы.

Возможная будущая работа:

Является ли аннотация чистоты достаточно полезной для обобщения и стандартизации?

curiousguy
источник
1
Ваш первый пример вводит в заблуждение. Я думаю, что вы просто делаете общее замечание, но сначала я подумал, что вы проводите аналогию с кодом в вопросе. Но void foo(unique_ptr<int> ptr)принимает объект класса по значению . Этот объект имеет член-указатель, но мы говорим о том, что сам объект класса передается по ссылке. (Поскольку это не просто копируемое копирование, поэтому его конструктор / деструктор должен быть непротиворечивым this.) Это реальный аргумент и не связан с первым примером передачи по ссылке явно ; в этом случае указатель передается в регистр.
Питер Кордес
@PeterCordes " вы делали аналогию с кодом в вопросе. " Я сделал именно это. " объект класса по значению " Да, я, вероятно, должен объяснить, что в общем случае не существует такой вещи, как "значение" объекта класса, поэтому по значению для нематематического типа это не "по значению". « Этот объект имеет член-указатель » Подобная ptr природа «умного ptr» не имеет значения; и так же член ptr «умного ptr». Ptr - это просто скаляр, похожий на int: я написал «умный fileno» пример, который показывает, что «владение» не имеет ничего общего с «переносом ptr».
любопытный парень
1
Значением объекта класса является его объект-представление. Ведь unique_ptr<T*>это тот же размер и расположение, что T*и в регистре. Тривиально копируемые объекты класса могут передаваться по значению в регистрах в x86-64 System V, как и большинство соглашений о вызовах. Это делает копию этого unique_ptrобъекта, в отличие от вашего intпримера , где вызываемая - х &i является адрес вызывающей это iпотому , что вы прошли по ссылке на уровне C ++ , а не только как деталь реализации ASM.
Питер Кордес
1
Ошибка, поправка к моему последнему комментарию. Это не просто сделать копию этого unique_ptrобъекта; он использует, std::moveтак что его можно безопасно скопировать, потому что это не приведет к 2 копиям одного и того же unique_ptr. Но для тривиально копируемого типа, да, он копирует весь агрегатный объект. Если это единственный член, хорошие соглашения о вызовах рассматривают его так же, как скаляр этого типа.
Питер Кордес
1
Выглядит лучше. Примечания: Для структур C, скомпилированных как C ++ - это бесполезный способ представить разницу между C ++. В C ++ struct{}это структура C ++. Возможно, вам следует сказать «простые структуры» или «в отличие от С». Потому что да, есть разница. Если вы используете atomic_intв качестве члена структуры, C будет неатомно копировать его, ошибка C ++ в конструкторе удаленных копий. Я забыл, что делает C ++ для структур с volatileчленами. C позволит вам struct tmp = volatile_struct;скопировать все это (полезно для SeqLock); С ++ не будет.
Питер Кордес