Skip to content

Instantly share code, notes, and snippets.

@Nekrolm
Created January 14, 2024 00:05
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Nekrolm/b7bb71ee5414ac232d85c45e6f061fb6 to your computer and use it in GitHub Desktop.
Save Nekrolm/b7bb71ee5414ac232d85c45e6f061fb6 to your computer and use it in GitHub Desktop.
How to move vectors of different types

Как перекладывают вектора

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

На C++ это может выглядеть так

std::vector<float> to_floats(std::vector<int> input) {
    std::vector<float> result(input.size());
    std::transform(begin(input), end(input), begin(result), [](int x) -> float { return x; });
    return result;
}

На Rust

fn to_floats(input: Vec<i32>) -> Vec<f32> {
    input.into_iter().map(|x| x as f32).collect()
}

Компилируем плюсовый вариант с помощью clang или gcc и включенными оптимизациями. И видим посередине

mov     qword ptr [rsp + 16], rax       # 8-byte Spill
mov     rbx, r15
sar     rbx, 2
mov     rdi, r15
mov     qword ptr [rsp + 8], rcx        # 8-byte Spill
call    operator new(unsigned long)@PLT
mov     rsi, qword ptr [rsp + 8]        # 8-byte Reload
mov     r14, rax

Выделяется память под новый вектор. Как и ожидалось. Кстати, а call operator delete для старого вектора нету. Это не ошибка кодогенерации. Вектор был передан by value, конструктор и деструктор такого параметра исполняются в контексте стек фрейма вызывающей стороны. Для локальной переменной result также не вызывается delete -- благодаря NRVO (named return value optimization), result помещается сразу в стек фрейм вызывающей стороны, там же будет и деструктор с delete.

Скомпилируем Rust вариант

example::to_floats:
        mov     rax, rdi
        mov     rcx, qword ptr [rsi]
        mov     rdx, qword ptr [rsi + 8]
        mov     rsi, qword ptr [rsi + 16]
        test    rsi, rsi
        je      .LBB0_7
        cmp     rsi, 8
        jae     .LBB0_3
        xor     edi, edi
        jmp     .LBB0_6
.LBB0_3:
        mov     rdi, rsi
        and     rdi, -8
        xor     r8d, r8d
.LBB0_4:
        movups  xmm0, xmmword ptr [rcx + 4*r8]
        movups  xmm1, xmmword ptr [rcx + 4*r8 + 16]
        cvtdq2ps        xmm0, xmm0
        cvtdq2ps        xmm1, xmm1
        movups  xmmword ptr [rcx + 4*r8], xmm0
        movups  xmmword ptr [rcx + 4*r8 + 16], xmm1
        add     r8, 8
        cmp     rdi, r8
        jne     .LBB0_4
        cmp     rsi, rdi
        je      .LBB0_7
.LBB0_6:
        xorps   xmm0, xmm0
        cvtsi2ss        xmm0, dword ptr [rcx + 4*rdi]
        movss   dword ptr [rcx + 4*rdi], xmm0
        lea     r8, [rdi + 1]
        mov     rdi, r8
        cmp     rsi, r8
        jne     .LBB0_6
.LBB0_7:
        mov     qword ptr [rax], rcx
        mov     qword ptr [rax + 8], rdx
        mov     qword ptr [rax + 16], rsi
        ret

Все поместилось в 40 строчек в отличие от С++... И тут нету аллокации памяти! Ну действительно: sizeof(i32) == sizeof(f32) и alignof(i32) == alignof(f32) и аллокатор для входного и выходного вектора один и тот же. Владение вектором не разделено ни с кем. Ничего не мешет выполнить преобразование на месте.

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

С++ язык достаточно низкоуровневый, так что если что-то не может произойти автоматически за счет оптимизаций компилятора, мы это что-то можем попытаться провернуть самостоятельно! Именно этим мы сейчас и займемся. Приготовьтесь увидеть самые чудовищные undefined и implementation defined способы!

Для начала немножко поменяем сигнатуру функции, чтоб

  1. Чуть точнее изложить наши намерения -- передать владение, а не сделать копию
  2. Немного сократить сгенерированный код
  3. Отстреливать себе ногу до тех пор пока мы не достигнем цели

Я также оставлю только Clang и переименую функцию: для достижения цели нам нужно научиться забирать владение буфером из вектора типа A и передавать его в вектор B. Как только мы это сделаем, дальнейшее преобразование -- это дело техники (да, как разработчики настоящих программ, а не абстрактного идеала описываемого стандартом, нам глубоко наплевать на strict aliasing):

void inplace_int_to_floats(void* data, size_t n) noexcept {
    int*     as_int = static_cast<int*>(data);
    float* as_float = static_cast<float*>(data);
    std::transform(as_int, as_int + n, as_float, [](int x) -> float { return x; });
}

Сосредоточимся на желаемой функции

template <class To, class From>
std::vector<To> transmute_move(std::vector<From>&& input) noexcept
requires (sizeof(To) == sizeof(From)) && (alignof(From) >= alignof(To))
    && std::is_trivially_destructible_v<To> // а на From тут не обязательно ограничение. Возможно создади утечку, но утечка это совершенно безопасно (с) Rust
    && (!std::is_same_v<To, bool>) && (!std::is_same_v<From, bool>) // потому что vector<bool> проклят

С ней вся наша конечная функция реализуется так

std::vector<float> to_floats(std::vector<int>&& input) noexcept {
    std::vector<float> casted = transmute_move<float>(std::move(input));
    inplace_int_to_floats(casted.data(), casted.size());
    return casted;
}

Далее я буду опускать перечисление requires ограничений

Способ 1. Брутальный. Разбиваем окно и вламываемся.

Что такое вектор? Да это ж три указателя! Резко врываемся, копируем, зануляем, уходим. Проклятый vector<bool>, устроенный совершенно по-другому, тут не рассматриваем

template <class To, class From>
std::vector<To> transmute_move(std::vector<From>&& input) noexcept
{
    std::vector<To> result;
    static_assert(sizeof(input) == sizeof(result));
    std::memcpy(&result, &input, sizeof(input));
    std::memset(&input, 0, sizeof(input)); // компиляторы любят удалять memset,
    // когда считают что он не нужен/не влияет на обозреваемый эффект
    // здесь memset не будет удален, поскольку его результат будет использоваться деструктором
    // вектора input во внешнем стэк-фрейме
    return result;
}

Работает!

Результат выглядит не хуже чем у rustc

to_floats(std::vector<int, std::allocator<int> >&&):         # @to_floats(std::vector<int, std::allocator<int> >&&)
        mov     rax, rdi
        mov     rcx, qword ptr [rsi + 16]
        mov     qword ptr [rdi + 16], rcx
        movups  xmm0, xmmword ptr [rsi]
        movups  xmmword ptr [rdi], xmm0
        xorps   xmm0, xmm0
        movups  xmmword ptr [rsi], xmm0
        mov     qword ptr [rsi + 16], 0
        mov     rcx, qword ptr [rdi]
        mov     rdi, qword ptr [rdi + 8]
        mov     rdx, rdi
        sub     rdx, rcx
        sub     rdi, rcx
        je      .LBB0_7
        add     rdi, -4
        mov     rsi, rcx
        cmp     rdi, 28
        jb      .LBB0_5
        shr     rdi, 2
        inc     rdi
        mov     r8, rdi
        and     r8, -8
        lea     rsi, [rcx + 4*r8]
        xor     r9d, r9d
.LBB0_3:                                # =>This Inner Loop Header: Depth=1
        movups  xmm0, xmmword ptr [rcx + 4*r9]
        movups  xmm1, xmmword ptr [rcx + 4*r9 + 16]
        cvtdq2ps        xmm0, xmm0
        cvtdq2ps        xmm1, xmm1
        movups  xmmword ptr [rcx + 4*r9], xmm0
        movups  xmmword ptr [rcx + 4*r9 + 16], xmm1
        add     r9, 8
        cmp     r8, r9
        jne     .LBB0_3
        cmp     rdi, r8
        je      .LBB0_7
.LBB0_5:
        and     rdx, -4
        add     rcx, rdx
.LBB0_6:                                # =>This Inner Loop Header: Depth=1
        xorps   xmm0, xmm0
        cvtsi2ss        xmm0, dword ptr [rsi]
        movss   dword ptr [rsi], xmm0
        add     rsi, 4
        cmp     rsi, rcx
        jne     .LBB0_6
.LBB0_7:
        ret

На 9 строчек ассемблера больше сгенерировано. Из-за memset для деструктора и ссылки.

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

Далее будем учитывать аллокатор, так что сигнатура transmute_move слегка поменяется

Способ 2. Обмануть и убедить переписать квартиру на себя

template <class To, class From, class Alloc>
auto transmute_move(std::vector<From, Alloc>&& input) noexcept
{
    using RebindAlloc = std::allocator_traits<Alloc>::template rebind_alloc<To>;
    using RebindVector = std::vector<To, RebindAlloc>;
    // это концептуальное требование к аллокаторам
    // https://en.cppreference.com/w/cpp/named_req/Allocator
    // если ваш аллокатор так не может -- у вас плохой аллокатор
    static_assert(std::is_constructible_v<RebindAlloc, Alloc>);
    RebindAlloc new_alloc { input.get_allocator() };
    // Спасибо C++20: мы можем украсть данные и подставить нужный аллокатор
    // нельзя просто взять и забрать старый аллокатор из input:
    // например, он может заполнять свежевыделенную память специальным user-defined 
    // значением, зависящим от типа аллокатора и хранимым внутри его структуры.
    // reinterpret_cast в этом случае приведет к получению неправильного значения  
    RebindVector result (
        reinterpret_cast<RebindVector&&>(std::move(input)),
        new_alloc
    );
    return result;
}

Тоже работает. Красиво, грубо, с нарушением всех норм приличия, с неопределенным поведением, полагающимся на реализацию move-конструктора вектора, но работает. Даже почти во всех случаях. Сломается, если у Alloc и RebindAlloc разный layout: смещения полей data_, end_ и capacity_end_ указателей поедут и все красиво рванет.

Попробуем учесть и это.

Способ 3. Ну мы же семья

Этот способ работает только с реализацией вектора из libstdc++ (gcc/clang default)

Упрощенно структура классов в ней следующая:

template <class T, class Alloc>
struct VectorBase : Alloc {
    T* data_;
    T* data_end_;
    T* capacity_end_;
    ...
};

template<class T, class Alloc>
class vector: protected VectorBase<T, Alloc> {
    ....
}

Нам достаточно отнаследоваться от std::vector, чтоб получить доступ ко всему.

template <class T, class Alloc>
struct ExposeVectorInternals : public std::vector<T, Alloc> {
    using std::vector<T, Alloc>::vector;

    template <class U, class A>
    static std::vector<T, Alloc> transmute_impl(std::vector<U, A> &&from) noexcept {
        // Честно и законно забираем данные из исходного вектора и перекладываем их в 
        // такой же, но другой
        alignas (std::vector<U, A>) char buffer[sizeof(std::vector<U>)];
        auto src_vec = new (buffer) std::vector<U, A>(std::move(from));
        // но у этого другого деструктор вызываться автоматически не будет
        auto src_data_ptr = reinterpret_cast<T*>(src_vec->data());
        static_assert(std::is_constructible_v<Alloc, A>);
        Alloc new_alloc { src_vec->get_allocator() };
        
        // Создаем новый вектор, но наш, имеющий доступ к приватным полям
        alignas (ExposeVectorInternals<T, Alloc>) char dst_buffer[sizeof(ExposeVectorInternals<T, Alloc>)];
        auto dst = new  (dst_buffer) ExposeVectorInternals<T, Alloc>( std::move(new_alloc) );
        // у него также деструктор вызываться не будет
        dst->_M_impl._M_start = src_data_ptr;
        dst->_M_impl._M_finish = src_data_ptr + src_vec->size();
        dst->_M_impl._M_end_of_storage = src_data_ptr + src_vec->capacity();

        std::vector<T, Alloc> result;
        dst->swap(result);
        return result;
    }
};

template <class To, class From, class Alloc>
auto transmute_move(std::vector<From, Alloc>&& input) noexcept
{
    using RebindAlloc = std::allocator_traits<Alloc>::template rebind_alloc<To>;
    return ExposeVectorInternals<To, RebindAlloc>::transmute_impl(std::move(input));
}

Работает! Не считая доступа к зарезервированным именам полей, совершенно честное решение. Но не идеальное...

  1. Полагается на отношения между size()/capacity() и внутренними указателями.
  2. Аллокатор при копировании себя может что-то аллоцировать и деаллоцировать в своем деструкторе. Например, это может быть какой-то уникальный строковый тэг. Тут создаются два промежуточных вектора, у которых не будет вызван деструктор -> возможны утечки

Способ 4. Ну мы же семья. По документам

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

template <class T, class Alloc>
struct ExposeVectorInternals : public std::vector<T, Alloc> {
    using std::vector<T, Alloc>::vector;

    T*& expose_data() {
        return this->_M_impl._M_start;
    }

    T*& expose_data_end() {
        return this->_M_impl._M_finish;        
    }
    T*& expose_capacity_end() {
        return this->_M_impl._M_end_of_storage;
    }


};

template <class To, class From, class Alloc>
auto transmute_move(std::vector<From, Alloc>&& input) noexcept
{
    using RebindAlloc = std::allocator_traits<Alloc>::template rebind_alloc<To>;
    static_assert(std::is_constructible_v<RebindAlloc, Alloc>);
    RebindAlloc new_alloc { input.get_allocator() };
    std::vector<To, RebindAlloc> result { std::move(new_alloc) };

    using ExposeFrom = ExposeVectorInternals<From, Alloc>;
    using ExposeTo = ExposeVectorInternals<To, RebindAlloc>;

    // Наш наследничек не может получить доступ напрямую к полям другого вектора
    // Но layout его и вектора одинаковый
    // кастим и грабим
    ExposeTo& to = reinterpret_cast<ExposeTo&>(result);
    ExposeFrom& from = reinterpret_cast<ExposeFrom&>(input);

    to.expose_data() = reinterpret_cast<To*>(std::exchange(from.expose_data(), nullptr));
    to.expose_data_end() = reinterpret_cast<To*>(std::exchange(from.expose_data_end(), nullptr));
    to.expose_capacity_end() = reinterpret_cast<To*>(std::exchange(from.expose_capacity_end(), nullptr));
    
    return result;
}

Тоже работает. Красота!

К сожалению, не все реализации стандартной библиотеки такие. В libc++ VectorBase отнаследован приватно.

Способ 5. Ключ под ковриком

А что если я скажу вам, что в C++ есть способ совершенно легально ПРОИГНОРИРОВАТЬ модификаторы доступа к полям классов, не выполняя никаких страшных reinterpret_castов, С-style кастов и прочего ужаса.

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

Как?!

С помощью явного инстанциирования шаблонов и member-pointers!

class A {
public: 
    int data;
private:
    int secret;
};

В C++ помимо обычных указателей, есть еще указатели на поля/методы классов

int A::* ptr_data = &A::data; // Ok
int A::* ptr_secret = &A::secret; // не компилируется, ибо секрет приватный

A val {};
val.*ptr_data = 55; // доступ к полю есть

Если мы сможем откуда-нибудь достать указатель на приватное поле, мы получим к нему доступ.

Взять указатель на приватное поле класса в C++ вне этого самого класса в 99% нормальных случаев нельзя. Но есть один ненормальный случай: явное инстанциирование шаблонов

class A {
public:
    int data;
private:
    int secret;
};

template <auto ptr>
struct secret_pointer {
    static constexpr auto secret = ptr;
};

// это компилируется -- явное инстанциирование шаблона
template struct secret_pointer<&A::secret>;

int main() {
    // a вот тут нет
    // using ptr = secret_pointer<&A::secret>;
}

Это совершенно правильное поведение. Класс A может в каком-нибудь из своих методов использовать этот шаблон с указателем на приватное поле. Почему бы и нет. Стандарт разрешает явно интсанциировать что угодно чтоб добавить определение тех или иных символов в вашу конкретную единицу трансляции. Все совершенно легитимно. Все равно ведь дальше инстанциирования ничего не пойдет, ведь мы же не может больше нигде к нему обратиться, да? Или нет?...

Ха! У нас же есть вычисления на этапе компиляции. И инстанциирование шаблонов их может запускать.

class A {
public:
    int data;

    void show() {
        std::cout << secret << "\n";
    }
private:
    int secret;
};

// Объявим шаблон, куда 
// путем зловещих манипуляций будем заталкивать указатель
// Этот шаблон не зависит от значения указателя (&A::secret), так что
// мы сможем обратиться к нему вне класса A.
// Главное заполнить значение
template <class StorageTag, class PointerType>
struct Secret {
    static inline PointerType pointer = {};
};

// Добавляем шаблон, который будет знать значение указателя
// Этот шаблон будем явно инстанциировать
template <class StorageTag, auto PointerValue>
struct Expose {
    // пишем в глобальную переменную от другого шаблона
    // дело сделано
    static const inline auto value = Secret<StorageTag, decltype(PointerValue)>::pointer = PointerValue; 
};


class SecretTag; // объявляем тэг, по которому сможем записать и достать значение указателя
template struct Expose<SecretTag, &A::secret>;

int main() {
    A val;
    auto secret_pointer = Secret<SecretTag, int A::*>::pointer;
    val.*secret_pointer = 42;
    val.show();
}

Работает. Осталось дело техники. К сожалению, для явного инстанциирования шаблонов нужно явно подставлять все типы. Так что метод будет совсем не generic, в отличие от предыдущих.

А также под каждую реализацию вектора нужно будет добираться до приватных полей по-разному.

Написание кода c этим прекрасным способом чтоб сделать желаемый move для вектора из libc++ я оставляю тебе, мой читатель, в качестве домашнего задания. Ничего сложного:

  1. идем в исходники https://github.com/llvm-mirror/libcxx/blob/master/include/vector
  2. находим где живут три приватных поля
  3. вытаскиваем указатели на них описанным образом
  4. узнаем, что с приватным базовым классом становится чуть-чуть сложнее
  5. используем всемогущий C-каст, чтоб добраться до базового класса
  6. достаем поля
  7. вы великолепны

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

  1. https://www.youtube.com/watch?v=SmlLdd1Q2V8
  2. https://en.cppreference.com/w/cpp/memory/allocator_traits
  3. https://learn.microsoft.com/en-us/cpp/cpp/pointer-to-member-operators-dot-star-and-star?view=msvc-170
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment