Чому T * може бути передано в регістр, але унікальний_ptr <T> не може?


85

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

Абстракції з нульовою вартістю немає

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

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

Один із пунктів, який містер Каррут висловлює близько 27:00, - це те, що для AB + C ++ потрібні параметри за значенням (деякі, але не всі; можливо - непомітивні типи - нетривіально конструювані типи?), Які потрібно передавати в пам'ять а не в межах реєстру.

Мої запитання:

  1. Це насправді вимога ABI на деяких платформах? (що?) Або, можливо, це лише песимізація в певних сценаріях?
  2. Чому такий ABI такий? Тобто, якщо поля структури / класу вміщуються в регістри або навіть в єдиний реєстр - чому ми не зможемо передати його в цей реєстр?
  3. Чи обговорював це питання комітет стандартів С ++ останніми роками чи коли-небудь?

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

8
Я не впевнений, що саме означає вимога ABI, але це не забороняє
вносити

6
Якби я мав здогадуватися, я б сказав, що це стосується нетривіальних функцій членів, які потребують thisвказівника, який вказує на дійсне місце. unique_ptrмає такі. Розповсюдження регістру для цієї мети ніби відкине всю оптимізацію "передавання в регістр".
StoryTeller - Невідповідь Моніки

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%, що будь-який унікальний_птр просто зникає після компіляції. Зрештою, це лише відкладений виклик деструктора, який відомий під час компіляції.
Загін одного чоловіка-мавпи

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

on 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. ІМО, зміна ABI може бути безперебійною, якщо функції, що використовують новий ABI, явно увімкнули мати новий інший зв'язок, наприклад, оголосити їх у extern "C++20" {}блоці (можливо, у новому просторі простори імен для міграції існуючих API). Так що лише код, складений на основі нових оголошень функції з новим зв'язком, може використовувати новий ABI.

Зауважте, що ABI не застосовується, коли викликана функція була вбудована. Як і при генерації коду часу зв’язку, компілятор може вбудовувати функції, визначені в інших підрозділах перекладу, або використовувати спеціальні умови викликів.


Коментарі не для розширеного обговорення; ця розмова була переміщена до чату .
Самуель Liew

8

Зі звичайними ABI, нетривіальний деструктор -> не може пройти в регістри

(Ілюстрація моменту у відповіді @ MaximEgorushkin на прикладі @ harold у коментарі; виправлено відповідно до коментаря @ Yakk.)

Якщо ви компілюєте:

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

Ви отримуєте:

test(Foo):
        mov     eax, edi
        ret

тобто Fooоб'єкт передається testв реєстр ( edi), а також повертається в регістр ( eax).

Коли деструктор не є тривіальним (як std::unique_ptrприклад ОП), звичайні ІВС вимагають розміщення на стеці. Це справедливо, навіть якщо деструктор взагалі не використовує адресу об'єкта.

Таким чином, навіть у крайньому випадку руйнівника нічого не робити, якщо ви компілюєте:

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

з марним завантаженням і зберіганням.


Я не переконаний у цьому аргументі. Нетривіальний деструктор не робить нічого, щоб заборонити правило нібито. Якщо адреса не спостерігається, немає абсолютно жодної причини, чому вона повинна бути такою. Тож відповідний компілятор міг із задоволенням занести його до реєстру, якщо це не змінить спостережуваної поведінки (і поточні компілятори насправді будуть робити це, якщо виклики відомі ).
ComicSansMS

1
На жаль, це інший шлях (я згоден, що щось із цього вже є поза розумом). Якщо бути точним: я не переконаний, що вказані вами причини обов'язково призведуть до можливої ​​ABI, яка дозволила би передавати струм std::unique_ptrв регістр невідповідно.
ComicSansMS

3
"тривіальний деструктор [ПОТРІБНІ ЦИТАТИ]" явно помилковий; якщо жоден код насправді не залежить від адреси, тоді як-значить, адреса не повинна існувати на фактичній машині . Адреса повинна існувати в абстрактній машині , але речі в абстрактній машині, які не впливають на реальну машину, - це речі , ніби їх дозволено усунути.
Якк - Адам Невраумон

2
@einpoklum У стандарті немає нічого, що б зареєструвало регістри держав. Ключове слово регістру просто зазначає "ви не можете прийняти адресу". Щодо стандарту, існує лише абстрактна машина. "як би" означає, що будь-яка реальна машинна реалізація повинна вести себе лише "так, як якщо б" абстрактна машина поводилася, аж до поведінки, не визначеної стандартом. Зараз виникають дуже складні проблеми навколо того, як об’єкт в реєстрі, про який усі багато говорили. Також практичні потреби мають закликати конвенції, які стандарт також не обговорює.
Якк - Адам Невраумон

1
@einpoklum Ні, у цій абстрактній машині всі речі мають адреси; але адреси можна спостерігати лише за певних обставин. registerКлючове слово було призначене , щоб зробити його тривіальним для фізичної машини в магазині що - то в реєстрі, блокуючи речі , які практично роблять його важче «не мають ніякого адреси» в фізичній машині.
Якк - Адам Невраумон

2

Це насправді вимога ABI на деяких платформах? (що?) Або, можливо, це лише песимізація в певних сценаріях?

Якщо щось видно на межі одиниці компенсації, то чи визначено це неявно чи явно, воно стає частиною ABI.

Чому такий ABI такий?

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

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

Тривіально копіюваний тип може передаватися в регістри, оскільки логічна операція копіювання може бути розділена на дві частини. Параметри копіюються в регістри, які використовуються для передачі параметрів абонентом, а потім скопіюються в локальну змінну викликом. Незалежно від того, локальна змінна має місце пам'яті чи ні, це стосується лише запитувача.

З іншого боку, тип конструктора, який потрібно використовувати для конструювання копій або переміщення, не може таким чином розділити операцію копіювання, тому вона повинна бути передана в пам'яті.

Чи обговорював це питання комітет стандартів С ++ останніми роками чи коли-небудь?

Я поняття не маю, чи органи з питань стандартизації це врахували.

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

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


Чи можете ви детальніше розповісти про те, як правильні руйнівні рухи дозволять передавати унікальний_птр в реєстр? Це буде тому, що це дозволить відмовитись від вимоги щодо адресного сховища?
einpoklum

Правильні руйнівні рухи дозволять запровадити концепцію тривіальних руйнівних рухів. Це дозволило б поділити зазначений тривіальний хід АБІ так само, як і сьогодні можуть банальні копії.
plugwash

Хоча ви також хочете додати правило, згідно з яким компілятор міг реалізовувати передачу параметрів як звичайний хід або копію, за яким слідує "тривіальний руйнівний хід", щоб гарантувати, що завжди можна було переходити в регістри, незалежно від того, звідки цей параметр прийшов.
plugwash

Тому що розмір регістра може містити вказівник, але унікальну структуру_ptr? Який розмірofof (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не має такої функціональності і не кодує поведінку видалення в стані; єдиний спосіб налаштування очищення - це створення іншої інстанції шаблону (іншого типу класу).
curiousguy

-1

Спершу нам потрібно повернутися до того, що означає переходити за значенням та за посиланням.

Для таких мов, як Java та SML, передача за значенням є простою (і немає проходу за посиланням), так само як і копіювання змінної величини, оскільки всі змінні є просто скалярами і мають вбудовану семантичну копію: вони є або тими, що вважаються арифметичними. введіть C ++ або "посилання" (вказівники з різною назвою та синтаксисом).

У C у нас є скалярні та визначені користувачем типи:

  • Скаляри мають числове або абстрактне значення (покажчики не є числами, вони мають абстрактне значення), яке копіюється.
  • Типи сукупних даних скопіювали всі можливі ініціалізовані члени:
    • для типів продуктів (масиви та структури): рекурсивно всі члени структур та елементів масивів копіюються (синтаксис функції C не дає можливості передавати масиви за значенням безпосередньо, лише масиви членів структури, але це деталізація ).
    • для типів суми (союзи): значення "активного члена" зберігається; Очевидно, що копія член на член не є для того, що не всі члени можуть бути ініціалізовані.

У визначених користувачем типів C ++ можуть бути визначені користувачем семантичні копії, які дозволяють по-справжньому "об'єктно-орієнтоване" програмування з об'єктами, що володіють своїми ресурсами та операціями "глибокої копії". У такому випадку операція копіювання - це дійсно виклик функції, яка майже може робити довільні операції.

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

(Я думаю, є виняток, коли структура всередині союзу копіюється.)

Отже, для типу класу єдиний спосіб (за допомогою зовнішніх копій) зробити новий екземпляр - це через конструктор (навіть для тих, у кого тривіальні конструктори, створені компілятором).

Ви не можете прийняти адресу rvalue через оператора unry, &але це не означає, що немає об’єкта rvalue; а об’єкт за визначенням має адресу ; і ця адреса навіть представлена ​​синтаксичною конструкцією: об’єкт типу класу може створювати лише конструктор, і він має thisвказівник; але для тривіальних типів не існує конструктора, написаного користувачем, тому не можна розміщувати його, thisпоки не буде створена копія та названа.

Для скалярного типу значення об'єкта - це значення, що є об'єктом, чисте математичне значення, що зберігається в об'єкті.

Для типу класу єдине поняття значення об'єкта - це інша копія об'єкта, яку може створити лише конструктор копій, реальна функція (хоча для тривіальних типів ця функція настільки тривіальна, вони іноді можуть бути створений без виклику конструктора). Це означає, що значення об'єкта є результатом зміни глобального стану програми шляхом виконання . Він не має доступу математично.

Тож передача за значенням насправді не є річчю: це передача за допомогою конструктора копій , що менш красиво. Очікується, що конструктор копій виконає розумну операцію "копіювання" відповідно до належної семантики типу об'єкта, зважаючи на його внутрішні інваріанти (це абстрактні користувацькі властивості, а не властиві C ++ властивості).

Перейти за значенням об'єкта класу означає:

  • створити інший екземпляр
  • потім зробіть виклик функції дії в цьому екземплярі.

Зауважте, що проблема не має нічого спільного з тим, чи є сама копія об'єктом з адресою: всі параметри функції є об'єктами та мають адресу (на мовному семантичному рівні).

Питання в тому, чи:

  • копія - це новий об'єкт, ініціалізований з чистою математичною величиною (справжньою чистою оцінкою) оригінального об'єкта, як у скалярів;
  • або копія - це значення оригінального об'єкта , як і для класів.

У випадку тривіального типу класу ви все одно можете визначити копію оригіналу члена-члена, тому ви можете визначити чисту оцінку оригіналу через тривіальність операцій копіювання (конструктор копіювання та призначення). Не так, як для довільних спеціальних функцій користувача: значення оригіналу повинно бути сконструйованою копією.

Об'єкти класу повинні бути побудовані абонентом; у конструктора формально є 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 визначається термінами класів програм, а не конкретних випадків, які можуть бути оптимізовані.

Можлива майбутня робота:

Чи достатньо корисна примітка про чистоту, щоб її узагальнити та стандартизувати?


1
Ваш перший приклад здається оманливим. Я думаю, що ти просто робиш крапку в цілому, але спочатку я подумав, що ти робиш аналогію з кодом у питанні. Але void foo(unique_ptr<int> ptr)приймає об’єкт класу за значенням . Цей об’єкт має член вказівника, але ми говоримо про сам клас об'єкта, який передається посиланням. (Тому що це не тривіально копіюється, тому його конструктору / деструктору потрібна послідовна this.) Це справжній аргумент і не пов'язаний з першим прикладом явного проходження посилання ; у цьому випадку вказівник передається в регістр.
Пітер Кордес

@PeterCordes " Ви робили аналогію з кодом у питанні ". Я зробив саме це. " об’єкт класу за значенням " Так. Ймовірно, я повинен пояснити, що в цілому не існує такого поняття, як "значення" об'єкта класу, тому значення для не математичного типу не є "за значенням". " Цей об'єкт має член вказівника. " Ptr-подібний характер "розумного ptr" не має значення; і так є ptr член "розумного ptr". Ptr - це просто скаляр, як int: я написав приклад "розумного файлу", який ілюструє, що "право власності" не має нічого спільного з "перенесенням ptr".
curiousguy

1
Значення об’єкта класу - його об'єкт-представлення. Бо unique_ptr<T*>це той самий розмір і макет, що T*і вписується в регістр. Об'єкти класу тривіально копіюються можуть передаватися за значенням у регістри в x86-64 System V, як і більшість конвенцій, що викликаються. Це робить копію цього unique_ptrоб'єкта, в відміну від вашого intприкладу , де викликається - х &i є адреса викликає це iтому , що ви пройшли по посиланню на рівні C ++ , а не тільки як деталь реалізації ASM.
Пітер Кордес

1
Помилка, виправлення мого останнього коментаря. Це не просто зробити копію цього unique_ptrоб'єкта; він використовує, std::moveтому безпечно скопіювати його, оскільки це не призведе до двох примірників одного і того ж unique_ptr. Але для тривіально копіюваного типу, так, він дійсно копіює весь об'єднаний об'єкт. Якщо це один член, добрі конвенції про виклики трактують його так само, як і скаляр цього типу.
Пітер Кордес

1
Виглядає краще. Примітки. Для структур C, складених як C ++ - це не корисний спосіб ввести різницю між C ++. В C ++ struct{}- це структура C ++. Можливо, вам слід сказати "звичайні структури", або "на відміну від C". Тому що так, є різниця. Якщо ви використовуєте atomic_intв якості члена структури, C буде копіювати його без атомарних змін, помилка C ++ на видаленому конструкторі копій. Я забуваю, що C ++ робить на структурах з volatileчленами. C дозволить вам struct tmp = volatile_struct;скопіювати всю справу (корисно для SeqLock); C ++ не буде.
Пітер Кордес
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.