Безопасная (и бесплатная) реинтерпретация размеров данных - PullRequest
2 голосов
/ 11 марта 2019

Я хотел написать свой собственный тип «маленький вектор», и первым препятствием было выяснить, как реализовать хранилище в стеке.

Я наткнулся на std::aligned_storage, который, кажется, специально предназначен для реализации произвольного хранилища в стеке, но мне очень неясно, что делать, а что нет, безопасно . cppreference.com удобно имеет пример использования std::aligned_storage, который я повторю здесь:

template<class T, std::size_t N>
class static_vector
{
    // properly aligned uninitialized storage for N T's
    typename std::aligned_storage<sizeof(T), alignof(T)>::type data[N];
    std::size_t m_size = 0;

public:
    // Create an object in aligned storage
    template<typename ...Args> void emplace_back(Args&&... args) 
    {
        if( m_size >= N ) // possible error handling
            throw std::bad_alloc{};

        // construct value in memory of aligned storage
        // using inplace operator new
        new(&data[m_size]) T(std::forward<Args>(args)...);
        ++m_size;
    }

    // Access an object in aligned storage
    const T& operator[](std::size_t pos) const 
    {
        // note: needs std::launder as of C++17
        return *reinterpret_cast<const T*>(&data[pos]);
    }

    // Delete objects from aligned storage
    ~static_vector() 
    {
        for(std::size_t pos = 0; pos < m_size; ++pos) {
            // note: needs std::launder as of C++17
            reinterpret_cast<T*>(&data[pos])->~T();
        }
    }
};

Почти все здесь имеет смысл для меня, за исключением тех двух комментариев, говорящих:

примечание: необходимо std::launder по состоянию на C ++ 17

Предложение «как» само по себе довольно запутанно; Означает ли это, что

  1. Этот код является неправильным или непереносимым, и в переносной версии следует использовать std::launder (который был представлен в C ++ 17) или

  2. C ++ 17 произвел серьезное изменение в правилах алиасинга / реинтерпретации памяти?

Проходя мимо этого, использование std::launder касается меня с точки зрения производительности. Насколько я понимаю, в большинстве случаев компилятору разрешается делать очень строгие предположения относительно псевдонимов памяти (в частности, указатели на разные типы не ссылаются на одну и ту же память), чтобы избежать избыточных загрузок памяти.

Я бы хотел сохранить , чтобы уровень уверенности в псевдонимах со стороны компилятора (т. Е. Доступ к T из моего маленького вектора был одинаково оптимизирован по сравнению с обычным T[] или T *), хотя из того, что я прочитал std::launder, это звучит как полный барьер для псевдонимов, то есть компилятор должен предположить, что он ничего не знает о происхождении отмытого указателя. Я бы опасался, что использование этого в каждом operator[] будет мешать обычному устранению хранилища нагрузки.

Возможно, компилятор умнее этого, или, может быть, я неправильно понимаю, как вообще работает std::launder. Несмотря на это, я действительно не чувствую, что знаю, что я делаю с этим уровнем взлома памяти C ++. Было бы здорово узнать, что я должен сделать для этого конкретного случая использования, но если бы кто-то мог просветить меня в отношении более общих правил, это было бы очень ценно.

Обновление (дальнейшее изучение)

Читая по этой проблеме, мое текущее понимание состоит в том, что пример, который я вставил здесь, имеет неопределенное поведение в соответствии со стандартом, если не используется std::launder. Тем не менее, небольшие эксперименты, которые демонстрируют то, что я бы назвал неопределенным поведением, не показывают, что Clang или GCC должны быть настолько строгими, насколько позволяет стандарт.

Давайте начнем с того, что явно небезопасно в случае наложения указателей:

float definitelyNotSafe(float *y, int *z) {
    *y = 5.0;
    *z = 7;
    return *y;
}

Как и следовало ожидать, и Clang, и GCC (с включенной оптимизацией и строгим алиасом) генерируют код, который всегда возвращает 5.0; эта функция не будет иметь «желаемого» поведения, если ей передано y и z с псевдонимом:

.LCPI1_0:
        .long   1084227584              # float 5
definitelyNotSafe(float*, int*):              # @definitelyNotSafe(float*, int*)
        mov     dword ptr [rdi], 1084227584
        mov     dword ptr [rsi], 7
        movss   xmm0, dword ptr [rip + .LCPI1_0] # xmm0 = mem[0],zero,zero,zero
        ret

Однако все становится немного страннее, когда создание указателей псевдонимов видно компилятору:

float somehowSafe(float x) {
    // Make some aliasing pointers
    auto y = &x;
    auto z = reinterpret_cast<int *>(y);

    *y = 5.0;
    *z = 7;

    return x;
}

В этом случае и Clang, и GCC (с -O3 и -fstrict-aliasing) генерируют код, который наблюдает изменение от x до z:

.LCPI0_0:
        .long   7                       # float 9.80908925E-45
somehowSafe(float):                       # @somehowSafe(float)
        movss   xmm0, dword ptr [rip + .LCPI0_0] # xmm0 = mem[0],zero,zero,zero
        ret

Тем не менее, это не значит, что компилятор гарантированно "воспользуется" неопределенным поведением; в конце концов, оно не определено. И в этом случае не было никакой выгоды, если предположить, что *z = 7 не имеет никакого эффекта. Так что, если мы "мотивировали" компилятор воспользоваться строгим псевдонимом?

int stillSomehowSafe(int x) {
    // Make some aliasing pointers
    auto y = &x;
    auto z = reinterpret_cast<float *>(y);

    auto product = float(x) * x * x * x * x * x;

    *y = 5;
    *z = product;

    return *y;
}

Очевидно, что преимущество компилятора состоит в том, что *z = product не влияет на значение *y; это позволило бы компилятору упростить эту функцию до функции, которая просто всегда возвращает 5. Тем не менее, сгенерированный код не делает такого предположения:

stillSomehowSafe(int):                  # @stillSomehowSafe(int)
        cvtsi2ss        xmm0, edi
        movaps  xmm1, xmm0
        mulss   xmm1, xmm0
        mulss   xmm1, xmm0
        mulss   xmm1, xmm0
        mulss   xmm1, xmm0
        mulss   xmm1, xmm0
        movd    eax, xmm1
        ret

Я довольно озадачен этим поведением. Я понимаю, что нам даны ноль гарантий относительно того, что компилятор будет делать при наличии неопределенного поведения, но я также удивлен тем, что ни Clang, ни GCC не более агрессивны с такого рода оптимизациями. Это заставляет меня задуматься, неправильно ли я понимаю стандарт, или же у Clang и GCC более слабые (и задокументированные) определения «строгого алиасинга».

1 Ответ

0 голосов
/ 26 марта 2019

std::launder существует главным образом для работы со сценариями, такими как std::optional или small_vector, где одно и то же хранилище может повторно использоваться для нескольких объектов с течением времени, , и эти объекты могут иметь значение const или могут иметьconst или референтные члены .

Он говорит оптимизатору "здесь есть T, но он может не совпадать с T, который у вас был раньше, поэтому const члены могутизменились, или ссылочные члены могут ссылаться на что-то другое ".

В отсутствие const или ссылочных членов, std::launder ничего не делает и не является необходимым.См http://eel.is/c++draft/ptr.launder#5

...