О [[trivial_abi]] в Clang-е

Original author: Arthur O’Dwyer
  • Translation
Наконец-то я написал пост про [[trivial_abi]]!

Это новая фирменная фича в транке Clang-а, новая по состоянию на февраль 2018. Это вендорское расширение языка C++, это не стандартный C++, она не поддерживается транком GCC, и нет активных предложений WG21 включить её в стандарт C++, насколько мне известно.



Я не принимал участие в реализации этой фичи. Я просто просматривал патчи в списке рассылки cfe-commits и тихо аплодировал про себя. Но это такая крутая фича, что я считаю, каждый должен про неё знать.

Итак, первое, с чего мы начнём: это не стандартный атрибут, и транк Clang-а не поддерживает для него стандартное написание атрибута [[trivial_abi]]. Вместо этого, вы должны писать его в старом стиле, как показано ниже:

__attribute__((trivial_abi))
__attribute__((__trivial_abi__))
[[clang::trivial_abi]]

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

#define TRIVIAL_ABI __attribute__((trivial_abi))

class TRIVIAL_ABI Widget {
    // ...
};


Какую проблему это решает?



Помните мой пост от 17.04.2018 где я показал две версии класса?

Прим. перев: Так как пост от 17.04.2018 имеет незначительный объём, я не стал публиковать его отдельно, а вставил прямо сюда под спойлер.
пост от 17.04.2018

Недостатки пропущенного вызова тривиального деструктора


См. список рассылки предложений к стандарту C++. Какая из этих двух функций — foo или bar, будет иметь лучший код, сгенерированный компилятором?

struct Integer {
    int value;
    ~Integer() {} // deliberately non-trivial
};

void foo(std::vector<int>& v) {
    v.back() *= 0xDEADBEEF;
    v.pop_back();
}

void bar(std::vector<Integer>& v) {
    v.back().value *= 0xDEADBEEF;
    v.pop_back();
}


Компилируем с помощью GCC и libstdc++. Догадаетесь правильно?

foo:
  movq   8(%rdi), %rax
  imull  $-559038737, -4(%rax), %edx
  subq   $4, %rax
  movl   %edx, (%rax)
  movq   %rax, 8(%rdi)
  ret

bar:
  subq   $4, 8(%rdi)
  ret


Вот что здесь происходит: GCC достаточно умный, чтобы понимать, что когда запускается деструктор для области памяти, её время жизни заканчивается, и все предшествующие записи в это участок памяти «мертвы». Но GCC также достаточно умён, чтобы понимать, что тривиальный деструктор (такой, как псевдодеструктор ~int()) не делает ничего, и не производит никаких эффектов.

Итак, функция bar вызывает pop_back, который запускает ~Integer(), который делает vec.back() «мёртвым», и GCC полностью удаляет умножение на 0xDEADBEEF.

С другой стороны, foo вызывает pop_back, который запускает псевдодеструктор ~int() (он может полностью пропустить вызов, но не делает этого), GCC видит, что он пустой и забывает про него. Следовательно, GCC не видит, что vec.back() мёртв, и не удаляет умножение на 0xDEADBEEF.

Это происходит для тривиального деструктора, но не для псевдодеструктора, такого, как ~int(). Заменим наш ~Integer() {} на ~Integer() = default; и увидим, как инструкция imull появилась снова!

struct Foo {
    int value;
    ~Foo() = default; // trivial
};

struct Bar {
    int value;
    ~Bar() {} // deliberately non-trivial
};

В том посте приведён код, в котором компилятор генерировал код для Foo хуже, чем для Bar. Стоит обсудить, почему это было неожиданно. Программисты интуитивно ожидают, что «тривиальный» код будет лучше, чтем «нетривиальный». В большинстве ситуаций это так. В частности, это так, когда делаем вызов функции либо возврат:

template<class T>
T incr(T obj) {
    obj.value += 1;
    return obj;
}

incr компилируется в следующий код:

leal   1(%rdi), %eax
retq

(leal — это команда x86, означающая “add”.) Мы видим, что наш 4-байтный obj передаётся в incr в регистре %edi, и мы добавляем 1 к его значению, и возвращаем его в %eax. Четыре байта на входе, четыре на выходе, легко и просто.

Сейчас посмотрим на incr (случай с нетривиальным деструктором).

movl   (%rsi), %eax
addl   $1, %eax
movl   %eax, (%rsi)
movl   %eax, (%rdi)
movq   %rdi, %rax
retq

Здесь, obj не передаётся в регистре, несмотря на то, что здесь те же самые 4 байта с той же самой семантикой. Здесь obj передаётся и возвращается по адресу. Здесь вызывающая сторона резервирует некоторое пространство для возвращаемого значения и передаёт нам указатель на это пространство в rdi, и вызывающая сторона даёт нам указатель для возвращаемого значения obj в следующем регистре аргументов %rsi. Мы извлекаем значение из (%rsi), прибавляем 1, сохраняем обратно в (%rsi), чтобы проапдейтить значение самого obj, и затем (тривиально) копируем 4 байта obj в слот для возвращаемого значения, на который указывает %rdi. Наконец, мы копируем оригинальный указатель, переданный вызывающей стороной, из %rdi в %rax, так как документ x86-64 ABI (стр. 22) велит нам это сделать.

Причина, по которой Bar настолько отличается от Foo состоит в том, что Bar имеет нетривиальный деструктор, а x86-64 ABI (стр. 19) особо утверждает:

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

Более поздний документ Itanium C++ ABI определяет следующее:
Если тип параметра нетривиален для цели вызова, вызывающая сторона должна аллоцировать временное место и передать ссылку на это временное место:
[…]
Тип рассматривается как нетривиальный для цели вызова, если:

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

Итак, это объясняет всё: Bar имеет более плохую генерацию кода, потому что он передаётся через невидимую ссылку. Он передаётся через невидимую ссылку так как произошло несчастливое стечение двух независимых обстоятельств:
  • документ ABI говорит, что объекты с нетривиальным деструктором передаются через невидимые ссылки
  • Bar имеет нетривиальный деструктор.

Это классический силлогизм: первый пункт — главная предпосылка, второй — частная. В итоге, Bar передаётся через невидимую ссылку.

Пусть кто-то дал нам силлогизм:
  • Все люди смертны
  • Сократ — человек.
  • Следовательно, Сократ смертен.


Если мы хотим опровергнуть заключение «Сократ смертен», мы должны опровергнуть одну из предпосылок: либо опровергнуть главную (возможно, некоторые люди не смертны), или опровергнуь частную (возможно, Сократ не человек).

Для того, чтобы Bar передавался в регистре, (как Foo), мы должны опровергнуть одну из двух предпосылок. Путь стандартного C++ — дать Bar тривиальный деструктор, уничтожив частную предпосылку. Но есть и другой путь!

Как [[trivial_abi]] решает проблему


Новый атрибут Clang-а уничтожает главную предпосылку. Clang расширяет документ ABI следующим образом:
Если тип параметра нетривиален для цели вызова, вызывающая сторона должна аллоцировать временное место и передать ссылку на это временное место:
[…]
Тип рассматривается как нетривиальный для цели вызова, если он помечен как [[trivial_abi]] и:
Он имеет нетривиальный копирующий конструктор, перемещающий конструктор, деструктор, либо все его перемещающие и копирующие конструкторы удалены.

Даже если класс с нетривиальным перемещающим конструктором или деструктором может рассматриваться как тривиальный для целей вызова, если он отмечен как [[trivial_abi]].

Итак, сейчас, используя Clang, мы можем написать так:

#define TRIVIAL_ABI __attribute__((trivial_abi))

struct TRIVIAL_ABI Baz {
    int value;
    ~Baz() {} // deliberately non-trivial
};

скомпилировать incr<Baz>, и получить тот же код, что и для incr< Foo>!

Предупреждение №1: [[trivial_abi]] иногда ничего не делает


Я бы надеялся, что мы могли бы сделать обёртки «тривиальный для целей вызова» над стандартными библиотечными типами, типа такого:

template<class T, class D>
struct TRIVIAL_ABI trivial_unique_ptr : std::unique_ptr<T, D> {
    using std::unique_ptr<T, D>::unique_ptr;
};

Увы, это не работает. Если ваш класс имеет любой базовый класс либо нестатические поля, которые сами по себе «нетривиалны для целей вызова», то расширение Clang в том виде, в котором оно написано сейчас, делает ваш класс «необратимо нетривиальным», и атрибут не будет иметь эффект. (При этом не выдаётся диагностических сообщений. Это означает, что вы можете использовать [[trivial_abi]] в шаблоне класса как опциональный атрибут, и класс будет «условно-тривиальным», что иногда полезно. Недостаток, конечно, в том, что вы можете пометить класс как тривиальный, и затем обнаружить, что компилятор это тихо исправил.)

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

Итак, насколько я знаю, единственный способ использовать TRIVIAL_ABI для «стандартных утилитарных типах», таких, как optional<T>, unique_ptr<T> и shared_ptr<T> — это
  • реализовать их самому с нуля и применить атрибут, либо
  • вломиться в вашу локальную копию libc++ и вставить атрибут туда руками

(в опенсорсном мире, оба способа, по сути, одинаковы)

Предупреждение №2: ответственность деструктора


В примере с Foo/Bar, класс имеет пустой деструктор. Пусть наш класс имеет на самом деле нетривиальный деструктор.

struct Up1 {
    int value;
    Up1(Up1&& u) : value(u.value) { u.value = 0; }
    ~Up1() { puts("destroyed"); }
};

Это должно быть вам знакомо, это unique_ptr<int>, упрощённый до предела, с печатью сообщения при удалении.

Без TRIVIAL_ABI, incr<Up1> выглядит просто как incr<Bar>:

movl   (%rsi), %eax
addl   $1, %eax
movl   %eax, (%rdi)
movl   $0, (%rsi)
movq   %rdi, %rax
retq


С TRIVIAL_ABI, incr выглядит больше и страшнее!

pushq  %rbx
leal   1(%rdi), %ebx
movl   $.L.str, %edi
callq  puts
movl   %ebx, %eax
popq   %rbx
retq


При традиционном соглашении о вызовах, типы с нетривиальным деструктором всегда передаются по невидимой ссылке, что означает, что принимающая сторона (incr в данном случае) всегда принимает указатель на объект параметра, не владея этим объектом. Объектом владеет вызывающая сторона, Это заставляет работать пропущенное копирование (elision work)!

Когда тип с [[trivial_abi]] передаётся в регистрах, мы, по сути, делаем копию объекта параметра.

Так как x86-64 имеет только один регистр для возврата (аплодисменты), вызываемая функция не имеет способа вернуть объект в конце. Вызываемая функция должна взять владение объектом, который мы ей передали! Это означает, что вызываемая функция должна вызвать деструктор объекта параметра, когда закончит.

В нашем предыдущем примере Foo/Bar/Baz, вызов деструктора происходит, но он был пустым, и мы его не заметили. Сейчас в incr<Up2> мы видим дополнительный код, который сгенерирован деструктором на стороне вызываемой функции.

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

Предупреждение №3: порядок деструкторов


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

struct TRIVIAL_ABI alpha {
    alpha() { puts("alpha constructed"); }
    ~alpha() { puts("alpha destroyed"); }
};
struct beta {
    beta() { puts("beta constructed"); }
    ~beta() { puts("beta destroyed"); }
};
void foo(alpha, beta) {}
int main() {
    foo(alpha{}, beta{});
}

Этот код печатает:

alpha constructed
beta constructed
alpha destroyed
beta destroyed

когда TRIVIAL_ABI определён как [[clang::trivial_abi]], он печатает:

alpha constructed
beta constructed
beta destroyed
alpha destroyed

Отношение с «тривиально релоцируемым»/«релоцируемым с перемещением» объектом (“trivially relocatable” / “move-relocates”)


Никакого отношения..., да?

Как видите, нет требований, чтобы [[trivial_abi]] класс имел какую-либо определённую семантику для перемещающего конструктора, деструктора, либо конструктора по умолчанию. Любой конкретный класс будет, вероятно, тривиально релоцируемым, просто потому что большинство классов являются тривиально релоцируемыми.

Мы можем просто сделать класс offset_ptr, чтобы он не был тривиально релоцируемым:

template<class T>
class TRIVIAL_ABI offset_ptr {
    intptr_t value_;
public:
    offset_ptr(T *p) : value_((const char*)p - (const char*)this) {}
    offset_ptr(const offset_ptr& rhs) : value_((const char*)rhs.get() - (const char*)this) {}
    T *get() const { return (T *)((const char *)this + value_); }
    offset_ptr& operator=(const offset_ptr& rhs) {
        value_ = ((const char*)rhs.get() - (const char*)this);
        return *this;
    }
    offset_ptr& operator+=(int diff) {
        value_ += (diff * sizeof (T));
        return *this;
    }
};

int main() {
    offset_ptr<int> top = &a[4];
    top = incr(top);
    assert(top.get() == &a[5]);
}

Вот полный код.
Когда TRIVIAL_ABI определён, транк Clang-а проходит этот тест при -O0 и -O1, но на -O2 (т.е. как только он пытается инлайнить вызовы trivial_offset_ptr::operator+= и копирующего конструктора), он падает на ассерте.

Итак, ещё одно предупреждение. Если ваш тип делает что-то настолько безумное с указателем this, вы, вероятно, не захотите передавать его в регистрах.

Баг 37319, по сути, запрос на документирование. В этом случае, выясняется, что не существует способа заставить код работать так, как хочет программист. Мы говорим, что значение value_ должно зависеть от значения указателя this, но на границе между вызывающей и вызываемой функциями объект находится в регистрах и указателя на него не существует! Поэтому вызывающая функция записывает его в память, и передаёт указатель this снова, и как вызываемая функция должна вычислить корректное значение, чтобы записать его в value_? Может быть, лучше спросить, как оно вообще работает при -O0? Этот код не должен работать вообще.

Итак, если вы хотите использовать [[trivial_abi]], вы должны избегать функций-членов (не только специальных, а любых вообще), которые существенно зависят от собственного адреса объекта (с некоторым неопределённым значением слова «существенно»).

Интуитивно понятно, что когда класс помечен как [[trivial_abi]], то всегда, когда вы ожидаете копирования, вы можете получить копирование плюс memcpy. И аналогично, когда вы ожидаете перемещение, вы можете получить на самом деле перемещение плюс memcpy.

Когда тип является «тривиально релоцируемым» (в соответствии с моим определением из C++Now), то в любое время, когда вы ожидаете копирование и уничтожение, вы можете на самом деле получить memcpy. И аналогично, когда вы ожидаете перемещение и уничтожение, вы можете на самом деле получить memcpy. На самом деле вызовы специальных функций потеряются, если мы говорим о «тривиальной релокации», но когда класс имеет атрибут [[trivial_abi]] Clang-а, вызовы не теряются. Вы просто получаете (как бы) memcpy вдобавок к вызовам, которые вы ожидали. Этот (как бы) memcpy — цена, которую вы платите за более быстрое, регистровое соглашение о вызовах.

Ссылки для дальнейшего чтения:


Akira Hatanaka’s cfe-dev thread from November 2017
Official Clang documentation
The unit tests for trivial_abi
Bug 37319: trivial_offset_ptr can’t possibly work
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 0

Only users with full accounts can post comments. Log in, please.