Skip to content

Latest commit

 

History

History
204 lines (154 loc) · 10.5 KB

aligned_storage.md

File metadata and controls

204 lines (154 loc) · 10.5 KB

std::aligned_storage

Всех С++ разработчиков можно довольно успешно разделить на две категории:

  • Те, у кого код вида
char buff[sizeof(T)];
...
T* obj = new (buff) T(...);

работает и для них нет никаких проблем

  • И те, у кого из-за такого кода внезапно прилетает SIGSEGV или SIGBUS, или что-нибудь еще более интересное.

Чаще всего первые собирают свои программы только под x86, каким-нибудь старым компилятором, ничего не знающим про SIMD инструкции, и с, вероятно, выключенными "агрессивными" оптимизациями, чтоб точно ничего не сломалось.

C разработчики тоже не должны уйти обиженными: для них этот код будет выглядеть как

char buff[sizeof(T)];
...
T* obj = (T*)buff;

Что в принципе еще страшнее из-за неинициализированной памяти, но это уже другая проблема.

Основная проблема: буфер, в который мы тут собрались что-то записать, может быть выровнен (alignment) не так как это требуется для типа T.

О том что размер укладываемых в буфер данных должен быть не больше размера самого буфера многие узнают довольно быстро и также быстро вспоминают. Хотя бы из соображений здравого смысла. А вот с выравниванием всё сложно.

Чтобы бездушная машина могла успешно прочитать/записать данные типа T по адресу соответствующему значению указателя T* ptr, или иногда совершить какую-то хитрую операцию над ними, в общем случае адрес должен быть выровнен — кратен некоторому числу (обычно это степень двойки), которое нам навязали инженеры-разработчики микроархитектуры этой самой машины. А навязали потому что:

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

Если мы вернемся в C++, то обычно мы знаем выравнивание, требуемое для встроенных типов:

Type alignment
char 1
int16 2
int32 4
int64 8
__m128i 16

Для других типов специализированных для работы с SSE/SIMD инструкциями может быть и больше.

Для пользовательских структур и классов — наследуется наибольшее выравнивание из всех полей. А между полями появляются неявные байты-заполнители (padding) чтобы удовлетворять требованиям выравнивания каждого поля по отдельности.

struct S {
    char c;    // 1
    // неявно char _padding[3] 
    int32_t i; // 4
};

static_assert(alignof(S) == 4);

Выравнивание массива соответствует выравниванию его элементов.

Поэтому

char buff[sizeof(T)]; // alignment == 1
...
T* obj = new (buff) T(...); // (uintptr_t)(obj) должен быть кратен alignof(T)
// но в этом коде гарантируется только то что он кратен 1

Для встроенных типов на x86 доступ по невыровненным указателям чаще всего приводит к просто к более медленному исполнению. На других платформах — может быть segfault. Но с SSE типами и на x86 можно легко получить segfault и довольно красиво

#include <memory>
#include <xmmintrin.h>


const size_t head = 0;
struct StaticStorage {
char buffer[256];
} storage;

int main() {
    __m128i* a = new (storage.buffer) __m128i();
    // comment line above & uncomment any line below for segfault
    // __m128i* b = new (storage.buffer + 0) __m128i();
    // __m128i* c = new (storage.buffer + head) __m128i();
}

Что же делать?! Как же написать код без такого интересного неопределенного поведения? Не волнуйтесь, С++11 спешит на помощь!

Стандарт предоставляет alignas спецификатор. С его помощью можно явно указать требования к выравниванию при описании переменных и структур

#include <memory>
#include <xmmintrin.h>


const size_t head = 0;
struct StaticStorage {
    alignas(__m128i) char buffer[256];
} storage;

int main() {
    __m128i* a = new (storage.buffer) __m128i();
    __m128i* b = new (storage.buffer + 0) __m128i();
    __m128i* c = new (storage.buffer + head) __m128i();
}

И вот уже и не падает

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

Конечно есть!

std::aligned_storage и его старший брат std::aligned_union

template<std::size_t Len, std::size_t Align = /* default alignment not implemented */>
struct aligned_storage
{
    struct type
    {
        alignas(Align) unsigned char data[Len];
    };
};

template <std::size_t Len, class... Types>
struct aligned_union
{
    static constexpr std::size_t alignment_value = std::max({alignof(Types)...});
 
    struct type
    {
      alignas(alignment_value) char _s[std::max({Len, sizeof(Types)...})];
    };
};

Это ж практически то же самое, что было в примере выше! Первый совсем низкоуровневый — ему нужно напрямую число-значение выравнивания указать. А второй более умный — он сам подходящее значение по списку типов выберет. Да еще и размер буфера подстроит, если мы неправильный указали. Какая удобная метафункция!

Давайте же ею воспользуемся.

#include <memory>
#include <xmmintrin.h>
#include <type_traits>


const size_t head = 0;
std::aligned_union<256, __m128i> storage;

int main() {
    __m128i* a = new (&storage) __m128i();
    __m128i* b = new ((char*)(&storage) + 0) __m128i();
    __m128i* c = new ((char*)&storage + head) __m128i();
}

И сразу же все упало.

Но как же так?! Ведь все же верно...

А давайте проверим

static_assert(sizeof(storage) >= 256);
<source>:9:1: error: static assertion failed due to requirement 'sizeof (storage) >= 256'
static_assert(sizeof(storage) >= 256);
^             ~~~~~~~~~~~~~~~~~~~~~~
<source>:9:31: note: expression evaluates to '1 >= 256'
static_assert(sizeof(storage) >= 256);

Дивно. Но если мы еще раз внимательно посмотрим на примеры определения шаблонов std::aligned_storage выше, то обнаружим великую подлость и предрасположенность этих шаблонов к ошибке использования.

Нам нужно использовать typename std::aligned_union<256, __m128i>::type storage!

Или, в С++17, std::aligned_union_t<256, __m128i> storage

Разница всего в два символа, а какой результат!

На момент написания этой заметки, gcc-12 способен из коробки выдать предупреждения

<source>:12:23: warning: placement new constructing an object of type '__m128i' and size '16' in a region of type 'std::aligned_union<256, __vector(2) long long int>' and size '1' [-Wplacement-new=]
   12 |     __m128i* a = new (&storage) __m128i();

наводящие на мысль об ошибке.

clang-16 по умолчанию такого не сообщает.

std::aligned_* признаны невероятно опасными к использованию. Из-за ужасного дизайна, ошибки в использовании которого очень легко прячутся.

В C++23 их пометили как deprecated. Но кто ж знает, когда мы увидим C++23 в больших и старых кодовых базах...

Если вы используете std::aligned_* в своем коде — убедитесь дважды, что вы используете его правильно. А лучше замените на свою структуру с явным использованием alignas.

Полезные ссылки

  1. https://en.cppreference.com/w/cpp/language/alignas
  2. https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p1413r3.pdf
  3. https://en.wikipedia.org/wiki/Streaming_SIMD_Extensions